# Search Results

## Events Dispatching Events Dispatching Events After Database Transactions
Events | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/events.md

### Dispatching Events After Database Transactions

Sometimes, you may want to instruct Laravel to only dispatch an event after the active database transaction has committed. To do so, you may implement the `ShouldDispatchAfterCommit` interface on the event class.

This interface instructs Laravel to not dispatch the event until the current database transaction is committed. If the transaction fails, the event will be discarded. If no database transaction is in progress when the event is dispatched, the event will be dispatched immediately:

```php
<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderShipped implements ShouldDispatchAfterCommit
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    /**
     * Create a new event instance.
     */
    public function __construct(
        public Order $order,
    ) {}
}
```

---

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

#### assertInvalid

Assert that the response has validation errors for the given keys. This method may be used for asserting against responses where the validation errors are returned as a JSON structure or where the validation errors have been flashed to the session:

```php
$response->assertInvalid(['name', 'email']);
```

You may also assert that a given key has a particular validation error message. When doing so, you may provide the entire message or only a small portion of the message:

```php
$response->assertInvalid([
    'name' => 'The name field is required.',
    'email' => 'valid email address',
]);
```

---

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

#### Eloquent Models

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

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

---

## Test Impact Analysis
Test Impact Analysis | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/test-impact-analysis.md

---
title: Test Impact Analysis (TIA)
description: Test Impact Analysis is a great way to drastically reduce the time it takes to run your test suite by re-running only the tests affected by your latest changes.
---

# Test Impact Analysis (TIA)

Test Impact Analysis is a great way to drastically reduce the time it takes to run your test suite by re-running only the tests affected by your latest changes. The first time you run with `--tia`, Pest records a graph of which tests depend on which files. Every run after that, Pest looks at what you changed, runs only the tests that touched those files, and replays cached results for everything else.

A typical Laravel suite that takes 15 seconds replays in under a second. Edits to a single Blade template re-run a handful of feature tests. Comment-only edits, formatter passes, and README touches re-run nothing at all.

To get started, just add the `--tia` flag to any Pest invocation:

```bash
./vendor/bin/pest --parallel --tia
```

The first run is the **baseline** — Pest enables a coverage driver (PCOV or Xdebug) and records the dependency graph as your tests execute. Expect a small overhead on this run only.

Every subsequent run is a **replay**. Pest compares your working tree against the baseline and re-runs only the tests affected by your changes:

```
Tests:    774 passed (2658 assertions, 7 affected, 767 replayed)
Duration: 0.74s
```

`affected` is the set of tests Pest actually executed. `replayed` is the set whose results were served from cache.

## How Pest Decides What To Run

For each file you've changed, Pest looks for tests that depend on it:

- **PHP source files** — your `app/` classes, controllers, models, helpers — are tracked through the coverage driver. A change to `app/Models/User.php` re-runs only the tests that touched `User`.
- **Migrations** are intersected with the tables each test queried during the baseline. A column rename in `create_users_table.php` re-runs only the tests that queried the `users` table.
- **Inertia pages** under `resources/js/Pages` re-run only the tests that server-side-rendered them.
- **Shared JS components** under `resources/js/Components`, `Layouts`, and friends re-run only the tests whose pages import them — Pest walks Vite's module graph to figure this out.
- **Blade templates** re-run only the tests that rendered them, including renders triggered by browser tests.
- **Anything else** — config files, route files, `.env`, files outside the recorded graph — falls through to a broader pattern. Editing `config/app.php` re-runs every test, because Pest can't statically prove which tests depend on it.

## Cosmetic Edits Don't Run Anything

Pest normalises file content before comparing, so cosmetic changes don't trigger any tests. PHP files have whitespace, line comments, and docblocks stripped before hashing. Blade strips `{{-- … --}}` comments. JS, TS, Vue, and Svelte files have line and block comments removed too.

The result: a comment-only edit, a Prettier reformat, a Pint pass, or a README tweak produces an identical hash, and the file never enters the changed set. Zero tests run.

## Modes

Pest supports a few flags alongside `--tia`:

| Flag | Behaviour |
|---|---|
| `--tia` | Replay if a baseline graph exists, otherwise record. |
| `--tia --fresh` | Discard any existing graph and re-record from scratch. Use this after large refactors or when the graph feels stale. |
| `--tia --refetch` | Force a CI baseline fetch even within the 24-hour cooldown after a previous failed fetch.

## Sharing The Baseline From CI

Recording the baseline locally takes minutes on large suites. Instead, you can have CI record it once per merge to `main`, and every developer downloads the result.

When Pest detects no local graph (or the local graph is out of date), it uses GitHub's CLI to download the latest successful run of a `tia-baseline.yml` workflow's `pest-tia-baseline` artifact. Pest validates the fetched graph against your project state — if it matches, it's adopted. Otherwise it's discarded and a local rebuild proceeds.

Here's a starter workflow you can drop into `.github/workflows/tia-baseline.yml`:

```yaml
name: TIA Baseline
on:
  push: { branches: [main] }
  schedule: [{ cron: '0 3 * * *' }]
  workflow_dispatch:
jobs:
  baseline:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: shivammathur/setup-php@v2
        with: { php-version: '8.5', coverage: xdebug }
      - run: composer install --no-interaction --prefer-dist
      - run: ./vendor/bin/pest --parallel --tia --coverage
      - name: Stage baseline for upload
        shell: bash
        run: |
          mkdir -p .pest-tia-baseline
          cp -R "$HOME/.pest/tia"/*/. .pest-tia-baseline/
      - uses: actions/upload-artifact@v4
        with:
          name: pest-tia-baseline
          path: .pest-tia-baseline/
          retention-days: 30
```

After CI runs, every developer who runs `pest --tia` for the first time on the repo will download this baseline and start replaying immediately, paying no record cost.

## Storage

Pest stores its state at `~/.pest/tia/<project-key>/`, where the project key is derived from your normalised git remote URL — so `git@github.com:foo/bar.git` and `https://github.com/foo/bar` produce the same key. A non-git project falls back to a hash of the project's absolute path.

Sharing state per remote URL means multiple worktrees of the same repository share one cache, while unrelated projects on the same machine stay isolated.

## Custom Watch Patterns

If your project has a directory layout that doesn't match the framework defaults, you can register custom watch patterns in `tests/Pest.php`:

```php
pest()->tia()->watch([
    'config/billing/**/*.php' => ['tests/Feature/Billing'],

    //
]);
```

Each glob maps to a list of test directories; whenever a matching file changes, every test under those directories is invalidated. Use this when you have specific test directories that genuinely depend on a file Pest can't see otherwise.

---

Now that you've learned how to use Test Impact Analysis to speed up your test suite, let's discuss how to integrate Pest with your continuous integration workflow: [Continuous Integration](/docs/continuous-integration)

---

## Telescope Available Watchers Job Watcher
Telescope | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/telescope.md

### Job Watcher

The job watcher records the data and status of any [jobs](/docs/{{version}}/queues) dispatched by your application.

---

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

#### Models and UUIDv7

**Likelihood Of Impact: Medium**

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

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

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

---

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

## Mutation Testing

Mutation Testing is an innovative new technique that introduces small changes (mutations) to your code to see if your tests catch them. This ensures you’re testing your application thoroughly, beyond just achieving code coverage and more about the actual quality of the tests. It’s a great way to identify weaknesses in your test suite and improve quality.

To get started with mutation testing, head over to your test file, and be specific about which part of your code your test covers using the `covers()` function.

```php
covers(TodoController::class);

it('list todos', function () {
    $this->getJson('/todos')->assertStatus(200);
});
```

Then, run Pest PHP with the `--mutate` option to start mutation testing.

```bash
./vendor/bin/pest --mutate
# or in parallel...
./vendor/bin/pest --mutate --parallel
```

Pest will then re-run your tests against "mutated" code and see if the tests are still passing. If a test is still passing against a mutation, it means that the test is not covering that specific part of the code. As, as result, Pest will output the mutation and the diff of the code.

```diff
UNTESTED  app/Http/TodoController.php  > Line 44: ReturnValue - ID: 76d17ad63bb7c307

class TodoController {
    public function index(): array
    {
         // pest detected that this code is untested because
         // the test is not covering the return value
-        return Todo::all()->toArray();
+        return [];
    }
}

  Mutations: 1 untested
  Score:     33.44%
```

Once you have identified the untested code, you can write additional tests to cover it.

```diff
covers(TodoController::class);

it('list todos', function () {
+    Todo::factory()->create(['name' => 'Buy milk']);

-    $this->getJson('/todos')->assertStatus(200);
+    $this->getJson('/todos')->assertStatus(200)->assertJson([['name' => 'Buy milk']]);
});
```

Then, you can re-run Pest with the `--mutate` option to see if the mutation is now "tested" and covered.

```bash
  Mutations: 1 tested
  Score:     100.00%
```

The higher the mutation score, the better your test suite is. A mutation score of 100% means that all mutations were "tested", which is the goal of mutation testing.

Now, if you see "untested" or "uncovered" mutations, or are a mutation score below 100%, typically means that you have **missing tests** or that **your tests are not covering all the edge cases**.

Our plugin is deeply integrated into Pest PHP. So, each time a mutation is introduced, Pest PHP will:

- **Only run the tests covering the mutated code** to speed up the process.
- **Cache as much as possible** to speed up the process on subsequent runs.
- If enabled, use **parallel execution to run multiple tests** in parallel to speed up the process.

There is so much more to explore with Mutation Testing, like `@pest-mutate-ignore` or `--mutate --everything`. You can learn more about it in our [Mutation Testing](/docs/mutation-testing) section.

---

## Eloquent Relationships Aggregating Related Models
Eloquent Relationships | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent-relationships.md

## Aggregating Related Models

---

## Filtering Tests `only()`
Filtering Tests | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/filtering-tests.md

### `only()`

During development, you may want to focus on running specific tests while excluding all others. Pest provides two ways to do this: running only tests in a specific file, or running only a specific test within a file.

#### Running Only Tests in a File

When working on a specific feature, you can mark all tests in a file to run exclusively by calling the `pest()->only()` function at the top of your test file:

```php
<?php

pest()->only();

test('first test', function () {
    // This will run
});

test('second test', function () {
    // This will also run
});
```

All tests in files with `pest()->only()` will run, while tests in other files will be skipped.

#### Running a Single Test

To run only a specific test within a file, chain the `->only()` method to the test:

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

  expect($result)->toBe(3);
})->only();

test('another test', function () {
  // This will be skipped
});
```

---

As your codebase grows, manually running your tests with filtering can become tedious. That's where skipping tests comes in. Skipping tests is a useful feature that allows developers to exclude specific tests from the test suite temporarily without deleting them entirely: [Skipping Tests →](/docs/skipping-tests)

---

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

### The Jobs Directory

This directory does not exist by default, but will be created for you if you execute the `make:job` Artisan command. The `Jobs` directory houses the [queueable jobs](/docs/{{version}}/queues) for your application. Jobs may be queued by your application or run synchronously within the current request lifecycle. Jobs that run synchronously during the current request are sometimes referred to as "commands" since they are an implementation of the [command pattern](https://en.wikipedia.org/wiki/Command_pattern).

---

## Scout Searching Where Clauses Customizing the Eloquent Results Query
Scout | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/scout.md

#### Customizing the Eloquent Results Query

After Scout retrieves a list of matching Eloquent models from your application's search engine, Eloquent is used to retrieve all of the matching models by their primary keys. You may customize this query by invoking the `query` method. The `query` method accepts a closure that will receive the Eloquent query builder instance as an argument:

```php
use App\Models\Order;
use Illuminate\Database\Eloquent\Builder;

$orders = Order::search('Star Trek')
    ->query(fn (Builder $query) => $query->with('invoices'))
    ->get();
```

When using a third-party engine, this callback is invoked after the relevant models have already been retrieved from the search engine, so it should not be used for "filtering" results — use [Scout where clauses](#where-clauses) instead. However, when using the database engine, the `query` method's constraints are applied directly to the database query, so you may use it for filtering as well.

---

## Queues Job Events
Queues | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/queues.md

## Job Events

Using the `before` and `after` methods on the `Queue` [facade](/docs/{{version}}/facades), you may specify callbacks to be executed before or after a queued job is processed. These callbacks are a great opportunity to perform additional logging or increment statistics for a dashboard. Typically, you should call these methods from the `boot` method of a [service provider](/docs/{{version}}/providers). For example, we may use the `AppServiceProvider` that is included with Laravel:

```php
<?php

namespace App\Providers;

use Illuminate\Support\Facades\Queue;
use Illuminate\Support\ServiceProvider;
use Illuminate\Queue\Events\JobProcessed;
use Illuminate\Queue\Events\JobProcessing;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        // ...
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        Queue::before(function (JobProcessing $event) {
            // $event->connectionName
            // $event->job
            // $event->job->payload()
        });

        Queue::after(function (JobProcessed $event) {
            // $event->connectionName
            // $event->job
            // $event->job->payload()
        });
    }
}
```

Using the `looping` method on the `Queue` [facade](/docs/{{version}}/facades), you may specify callbacks that execute before the worker attempts to fetch a job from a queue. For example, you might register a closure to rollback any transactions that were left open by a previously failed job:

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

Queue::looping(function () {
    while (DB::transactionLevel() > 0) {
        DB::rollBack();
    }
});
```

---

## Scheduling Defining Schedules Running Tasks on One Server Naming Single Server Jobs
Scheduling | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/scheduling.md

#### Naming Single Server Jobs

Sometimes you may need to schedule the same job to be dispatched with different parameters, while still instructing Laravel to run each permutation of the job on a single server. To accomplish this, you may assign each schedule definition a unique name via the `name` method:

```php
Schedule::job(new CheckUptime('https://laravel.com'))
    ->name('check_uptime:laravel.com')
    ->everyFiveMinutes()
    ->onOneServer();

Schedule::job(new CheckUptime('https://vapor.laravel.com'))
    ->name('check_uptime:vapor.laravel.com')
    ->everyFiveMinutes()
    ->onOneServer();
```

Similarly, scheduled closures must be assigned a name if they are intended to be run on one server:

```php
Schedule::call(fn () => User::resetApiRequestCount())
    ->name('reset-api-request-count')
    ->daily()
    ->onOneServer();
```

---

## Scheduling Defining Schedules Scheduling Queued Jobs
Scheduling | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/scheduling.md

### Scheduling Queued Jobs

The `job` method may be used to schedule a [queued job](/docs/{{version}}/queues). This method provides a convenient way to schedule queued jobs without using the `call` method to define closures to queue the job:

```php
use App\Jobs\Heartbeat;
use Illuminate\Support\Facades\Schedule;

Schedule::job(new Heartbeat)->everyFiveMinutes();
```

Optional second and third arguments may be provided to the `job` method which specifies the queue name and queue connection that should be used to queue the job:

```php
use App\Jobs\Heartbeat;
use Illuminate\Support\Facades\Schedule;

// Dispatch the job to the "heartbeats" queue on the "sqs" connection...
Schedule::job(new Heartbeat, 'heartbeats', 'sqs')->everyFiveMinutes();
```

---

## Arch Testing Modifiers `abstracts()`
Arch Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/arch-testing.md

### `abstracts()`

The `abstracts()` modifier allows you to restrict the expectation to only abstract classes.

```php
arch('app')
    ->expect('App')
    ->abstracts()
    ->toImplement(JsonSerializable::class);
```
---

In this section, you have learned how to perform architectural testing, ensuring that your application or library's architecture meets a specified set of architectural requirements. Next, have you ever wondered how to test the performance of your code? Let's explore [Stress Testing](/docs/stress-testing).

---

## Responses Redirects Redirecting to Named Routes Populating Parameters via Eloquent Models
Responses | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/responses.md

#### Populating Parameters via Eloquent Models

If you are redirecting to a route with an "ID" parameter that is being populated from an Eloquent model, you may pass the model itself. The ID will be extracted automatically:

```php
// For a route with the following URI: /profile/{id}

return redirect()->route('profile', [$user]);
```

If you would like to customize the value that is placed in the route parameter, you can specify the column in the route parameter definition (`/profile/{id:slug}`) or you can override the `getRouteKey` method on your Eloquent model:

```php
/**
 * Get the value of the model's route key.
 */
public function getRouteKey(): mixed
{
    return $this->slug;
}
```

---

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

### waitForKey

The `waitForKey` method opens the current page URL in the default web browser and waits for a key press:

```php
$page->waitForKey(); // Useful for debugging
```

## Debugging tests

Sometimes you may want to debug your browser tests. Pest provides a convenient way to do this by using the `--debug` option, which makes pest to open the browser window and pause the execution of the test when it fails. You can then inspect the page and see what went wrong.

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

Optionally, you can also use the `debug()` method in your test. It will limit execution to this test (like using [`only()`](filtering-tests.md#only)), pause the execution and open the browser window:

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

You can also take a screenshot of the current page using the `screenshot()` method. This is useful for visual debugging:

NOTE: If you don't pass the filename, it will use the test name as the filename.

```php
$page->screenshot();
$page->screenshot(fullPage: true);
$page->screenshot(filename: 'custom-name');
```

You can also take a screenshot of a specific element using the `screenshotElement()` method:

```php
$page->screenshotElement('#my-element');
```

You can also use the `tinker()` method to open a Tinker session in the context of the current page. This allows you to interact with the page using PHP code:

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

After you can run your tests with the `--headed` option to open the browser window:

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

If you wish to run the tests in a headed mode by default, you can set it in your `Pest.php` configuration file:

```php
pest()->browser()->headed();
```

## Continuous Integration

You may refer to Pest's [Continuous Integration](https://pestphp.com/docs/continuous-integration) documentation for more information on how to run your browser tests in a CI environment.

However, if you are using GitHub Actions, you need to add the following steps to your workflow file:

```yaml
    - uses: actions/setup-node@v4
      with:
        node-version: lts/*

    - name: Install dependencies
      run: npm ci

    - name: Install Playwright Browsers
      run: npx playwright install --with-deps
```

---

Now, let's dive into architectural testing and how it can benefit your development process. By performing architectural testing, you can evaluate the overall design of your application and identify potential flaws before they become significant issues: [Arch Testing](/docs/arch-testing)

_[Truncated]_


Output:
Wall time: 3.4415 seconds
Output:
[{"type":"text","text":"# Search Results\n\n## Events Dispatching Events Dispatching Events After Database Transactions\nEvents | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/events.md\n\n\n### Dispatching Events After Database Transactions\n\nSometimes, you may want to instruct Laravel to only dispatch an event after the active database transaction has committed. To do so, you may implement the `ShouldDispatchAfterCommit` interface on the event class.\n\nThis interface instructs Laravel to not dispatch the event until the current database transaction is committed. If the transaction fails, the event will be discarded. If no database transaction is in progress when the event is dispatched, the event will be dispatched immediately:\n\n```php\n<?php\n\nnamespace App\\Events;\n\nuse App\\Models\\Order;\nuse Illuminate\\Broadcasting\\InteractsWithSockets;\nuse Illuminate\\Contracts\\Events\\ShouldDispatchAfterCommit;\nuse Illuminate\\Foundation\\Events\\Dispatchable;\nuse Illuminate\\Queue\\SerializesModels;\n\nclass OrderShipped implements ShouldDispatchAfterCommit\n{\n    use Dispatchable, InteractsWithSockets, SerializesModels;\n\n    /**\n     * Create a new event instance.\n     */\n    public function __construct(\n        public Order $order,\n    ) {}\n}\n```\n\n---\n\n## Http Tests Validation Assertions assertInvalid\nHttp Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md\n\n\n#### assertInvalid\n\nAssert that the response has validation errors for the given keys. This method may be used for asserting against responses where the validation errors are returned as a JSON structure or where the validation errors have been flashed to the session:\n\n```php\n$response->assertInvalid(['name', 'email']);\n```\n\nYou may also assert that a given key has a particular validation error message. When doing so, you may provide the entire message or only a small portion of the message:\n\n```php\n$response->assertInvalid([\n    'name' => 'The name field is required.',\n    'email' => 'valid email address',\n]);\n```\n\n---\n\n## Urls URLs for Named Routes Eloquent Models\nUrls | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/urls.md\n\n\n#### Eloquent Models\n\nYou will often be generating URLs using the route key (typically the primary key) of [Eloquent models](/docs/{{version}}/eloquent). For this reason, you may pass Eloquent models as parameter values. The `route` helper will automatically extract the model's route key:\n\n```php\necho route('post.show', ['post' => $post]);\n```\n\n---\n\n## Test Impact Analysis\nTest Impact Analysis | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/test-impact-analysis.md\n\n---\ntitle: Test Impact Analysis (TIA)\ndescription: Test Impact Analysis is a great way to drastically reduce the time it takes to run your test suite by re-running only the tests affected by your latest changes.\n---\n\n# Test Impact Analysis (TIA)\n\nTest Impact Analysis is a great way to drastically reduce the time it takes to run your test suite by re-running only the tests affected by your latest changes. The first time you run with `--tia`, Pest records a graph of which tests depend on which files. Every run after that, Pest looks at what you changed, runs only the tests that touched those files, and replays cached results for everything else.\n\nA typical Laravel suite that takes 15 seconds replays in under a second. Edits to a single Blade template re-run a handful of feature tests. Comment-only edits, formatter passes, and README touches re-run nothing at all.\n\nTo get started, just add the `--tia` flag to any Pest invocation:\n\n```bash\n./vendor/bin/pest --parallel --tia\n```\n\nThe first run is the **baseline** — Pest enables a coverage driver (PCOV or Xdebug) and records the dependency graph as your tests execute. Expect a small overhead on this run only.\n\nEvery subsequent run is a **replay**. Pest compares your working tree against the baseline and re-runs only the tests affected by your changes:\n\n```\nTests:    774 passed (2658 assertions, 7 affected, 767 replayed)\nDuration: 0.74s\n```\n\n`affected` is the set of tests Pest actually executed. `replayed` is the set whose results were served from cache.\n\n## How Pest Decides What To Run\n\nFor each file you've changed, Pest looks for tests that depend on it:\n\n- **PHP source files** — your `app/` classes, controllers, models, helpers — are tracked through the coverage driver. A change to `app/Models/User.php` re-runs only the tests that touched `User`.\n- **Migrations** are intersected with the tables each test queried during the baseline. A column rename in `create_users_table.php` re-runs only the tests that queried the `users` table.\n- **Inertia pages** under `resources/js/Pages` re-run only the tests that server-side-rendered them.\n- **Shared JS components** under `resources/js/Components`, `Layouts`, and friends re-run only the tests whose pages import them — Pest walks Vite's module graph to figure this out.\n- **Blade templates** re-run only the tests that rendered them, including renders triggered by browser tests.\n- **Anything else** — config files, route files, `.env`, files outside the recorded graph — falls through to a broader pattern. Editing `config/app.php` re-runs every test, because Pest can't statically prove which tests depend on it.\n\n## Cosmetic Edits Don't Run Anything\n\nPest normalises file content before comparing, so cosmetic changes don't trigger any tests. PHP files have whitespace, line comments, and docblocks stripped before hashing. Blade strips `{{-- … --}}` comments. JS, TS, Vue, and Svelte files have line and block comments removed too.\n\nThe result: a comment-only edit, a Prettier reformat, a Pint pass, or a README tweak produces an identical hash, and the file never enters the changed set. Zero tests run.\n\n## Modes\n\nPest supports a few flags alongside `--tia`:\n\n| Flag | Behaviour |\n|---|---|\n| `--tia` | Replay if a baseline graph exists, otherwise record. |\n| `--tia --fresh` | Discard any existing graph and re-record from scratch. Use this after large refactors or when the graph feels stale. |\n| `--tia --refetch` | Force a CI baseline fetch even within the 24-hour cooldown after a previous failed fetch.\n\n## Sharing The Baseline From CI\n\nRecording the baseline locally takes minutes on large suites. Instead, you can have CI record it once per merge to `main`, and every developer downloads the result.\n\nWhen Pest detects no local graph (or the local graph is out of date), it uses GitHub's CLI to download the latest successful run of a `tia-baseline.yml` workflow's `pest-tia-baseline` artifact. Pest validates the fetched graph against your project state — if it matches, it's adopted. Otherwise it's discarded and a local rebuild proceeds.\n\nHere's a starter workflow you can drop into `.github/workflows/tia-baseline.yml`:\n\n```yaml\nname: TIA Baseline\non:\n  push: { branches: [main] }\n  schedule: [{ cron: '0 3 * * *' }]\n  workflow_dispatch:\njobs:\n  baseline:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with: { fetch-depth: 0 }\n      - uses: shivammathur/setup-php@v2\n        with: { php-version: '8.5', coverage: xdebug }\n      - run: composer install --no-interaction --prefer-dist\n      - run: ./vendor/bin/pest --parallel --tia --coverage\n      - name: Stage baseline for upload\n        shell: bash\n        run: |\n          mkdir -p .pest-tia-baseline\n          cp -R \"$HOME/.pest/tia\"/*/. .pest-tia-baseline/\n      - uses: actions/upload-artifact@v4\n        with:\n          name: pest-tia-baseline\n          path: .pest-tia-baseline/\n          retention-days: 30\n```\n\nAfter CI runs, every developer who runs `pest --tia` for the first time on the repo will download this baseline and start replaying immediately, paying no record cost.\n\n## Storage\n\nPest stores its state at `~/.pest/tia/<project-key>/`, where the project key is derived from your normalised git remote URL — so `git@github.com:foo/bar.git` and `https://github.com/foo/bar` produce the same key. A non-git project falls back to a hash of the project's absolute path.\n\nSharing state per remote URL means multiple worktrees of the same repository share one cache, while unrelated projects on the same machine stay isolated.\n\n## Custom Watch Patterns\n\nIf your project has a directory layout that doesn't match the framework defaults, you can register custom watch patterns in `tests/Pest.php`:\n\n```php\npest()->tia()->watch([\n    'config/billing/**/*.php' => ['tests/Feature/Billing'],\n\n    //\n]);\n```\n\nEach glob maps to a list of test directories; whenever a matching file changes, every test under those directories is invalidated. Use this when you have specific test directories that genuinely depend on a file Pest can't see otherwise.\n\n---\n\nNow that you've learned how to use Test Impact Analysis to speed up your test suite, let's discuss how to integrate Pest with your continuous integration workflow: [Continuous Integration](/docs/continuous-integration)\n\n\n---\n\n## Telescope Available Watchers Job Watcher\nTelescope | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/telescope.md\n\n\n### Job Watcher\n\nThe job watcher records the data and status of any [jobs](/docs/{{version}}/queues) dispatched by your application.\n\n---\n\n## Upgrade Upgrading To 12.0 From 11.x Eloquent Models and UUIDv7\nUpgrade | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/upgrade.md\n\n\n#### Models and UUIDv7\n\n**Likelihood Of Impact: Medium**\n\nThe `HasUuids` trait now returns UUIDs that are compatible with version 7 of the UUID spec (ordered UUIDs). If you would like to continue using ordered UUIDv4 strings for your model's IDs, you should now use the `HasVersion4Uuids` trait:\n\n```php\nuse Illuminate\\Database\\Eloquent\\Concerns\\HasUuids; // [tl! remove]\nuse Illuminate\\Database\\Eloquent\\Concerns\\HasVersion4Uuids as HasUuids; // [tl! add]\n```\n\nThe `HasVersion7Uuids` trait has been removed. If you were previously using this trait, you should use the `HasUuids` trait instead, which now provides the same behavior.\n\n---\n\n## Pest3 Now Available Mutation Testing\nPest3 Now Available | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/pest3-now-available.md\n\n\n## Mutation Testing\n\nMutation Testing is an innovative new technique that introduces small changes (mutations) to your code to see if your tests catch them. This ensures you’re testing your application thoroughly, beyond just achieving code coverage and more about the actual quality of the tests. It’s a great way to identify weaknesses in your test suite and improve quality.\n\n\n\nTo get started with mutation testing, head over to your test file, and be specific about which part of your code your test covers using the `covers()` function.\n\n```php\ncovers(TodoController::class);\n\nit('list todos', function () {\n    $this->getJson('/todos')->assertStatus(200);\n});\n```\n\nThen, run Pest PHP with the `--mutate` option to start mutation testing.\n\n```bash\n./vendor/bin/pest --mutate\n# or in parallel...\n./vendor/bin/pest --mutate --parallel\n```\n\nPest will then re-run your tests against \"mutated\" code and see if the tests are still passing. If a test is still passing against a mutation, it means that the test is not covering that specific part of the code. As, as result, Pest will output the mutation and the diff of the code.\n\n```diff\nUNTESTED  app/Http/TodoController.php  > Line 44: ReturnValue - ID: 76d17ad63bb7c307\n\nclass TodoController {\n    public function index(): array\n    {\n         // pest detected that this code is untested because\n         // the test is not covering the return value\n-        return Todo::all()->toArray();\n+        return [];\n    }\n}\n\n  Mutations: 1 untested\n  Score:     33.44%\n```\n\nOnce you have identified the untested code, you can write additional tests to cover it.\n\n```diff\ncovers(TodoController::class);\n\nit('list todos', function () {\n+    Todo::factory()->create(['name' => 'Buy milk']);\n\n-    $this->getJson('/todos')->assertStatus(200);\n+    $this->getJson('/todos')->assertStatus(200)->assertJson([['name' => 'Buy milk']]);\n});\n```\n\nThen, you can re-run Pest with the `--mutate` option to see if the mutation is now \"tested\" and covered.\n\n```bash\n  Mutations: 1 tested\n  Score:     100.00%\n```\n\nThe higher the mutation score, the better your test suite is. A mutation score of 100% means that all mutations were \"tested\", which is the goal of mutation testing.\n\nNow, if you see \"untested\" or \"uncovered\" mutations, or are a mutation score below 100%, typically means that you have **missing tests** or that **your tests are not covering all the edge cases**.\n\nOur plugin is deeply integrated into Pest PHP. So, each time a mutation is introduced, Pest PHP will:\n\n- **Only run the tests covering the mutated code** to speed up the process.\n- **Cache as much as possible** to speed up the process on subsequent runs.\n- If enabled, use **parallel execution to run multiple tests** in parallel to speed up the process.\n\nThere is so much more to explore with Mutation Testing, like `@pest-mutate-ignore` or `--mutate --everything`. You can learn more about it in our [Mutation Testing](/docs/mutation-testing) section.\n\n---\n\n## Eloquent Relationships Aggregating Related Models\nEloquent Relationships | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/eloquent-relationships.md\n\n\n## Aggregating Related Models\n\n---\n\n## Filtering Tests `only()`\nFiltering Tests | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/filtering-tests.md\n\n\n### `only()`\n\nDuring development, you may want to focus on running specific tests while excluding all others. Pest provides two ways to do this: running only tests in a specific file, or running only a specific test within a file.\n\n#### Running Only Tests in a File\n\nWhen working on a specific feature, you can mark all tests in a file to run exclusively by calling the `pest()->only()` function at the top of your test file:\n\n```php\n<?php\n\npest()->only();\n\ntest('first test', function () {\n    // This will run\n});\n\ntest('second test', function () {\n    // This will also run\n});\n```\n\nAll tests in files with `pest()->only()` will run, while tests in other files will be skipped.\n\n#### Running a Single Test\n\nTo run only a specific test within a file, chain the `->only()` method to the test:\n\n```php\ntest('sum', function () {\n  $result = sum(1, 2);\n\n  expect($result)->toBe(3);\n})->only();\n\ntest('another test', function () {\n  // This will be skipped\n});\n```\n\n---\n\nAs your codebase grows, manually running your tests with filtering can become tedious. That's where skipping tests comes in. Skipping tests is a useful feature that allows developers to exclude specific tests from the test suite temporarily without deleting them entirely: [Skipping Tests →](/docs/skipping-tests)\n\n---\n\n## Structure The App Directory The Jobs Directory\nStructure | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/structure.md\n\n\n### The Jobs Directory\n\nThis directory does not exist by default, but will be created for you if you execute the `make:job` Artisan command. The `Jobs` directory houses the [queueable jobs](/docs/{{version}}/queues) for your application. Jobs may be queued by your application or run synchronously within the current request lifecycle. Jobs that run synchronously during the current request are sometimes referred to as \"commands\" since they are an implementation of the [command pattern](https://en.wikipedia.org/wiki/Command_pattern).\n\n---\n\n## Scout Searching Where Clauses Customizing the Eloquent Results Query\nScout | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/scout.md\n\n\n#### Customizing the Eloquent Results Query\n\nAfter Scout retrieves a list of matching Eloquent models from your application's search engine, Eloquent is used to retrieve all of the matching models by their primary keys. You may customize this query by invoking the `query` method. The `query` method accepts a closure that will receive the Eloquent query builder instance as an argument:\n\n```php\nuse App\\Models\\Order;\nuse Illuminate\\Database\\Eloquent\\Builder;\n\n$orders = Order::search('Star Trek')\n    ->query(fn (Builder $query) => $query->with('invoices'))\n    ->get();\n```\n\nWhen using a third-party engine, this callback is invoked after the relevant models have already been retrieved from the search engine, so it should not be used for \"filtering\" results — use [Scout where clauses](#where-clauses) instead. However, when using the database engine, the `query` method's constraints are applied directly to the database query, so you may use it for filtering as well.\n\n---\n\n## Queues Job Events\nQueues | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/queues.md\n\n\n## Job Events\n\nUsing the `before` and `after` methods on the `Queue` [facade](/docs/{{version}}/facades), you may specify callbacks to be executed before or after a queued job is processed. These callbacks are a great opportunity to perform additional logging or increment statistics for a dashboard. Typically, you should call these methods from the `boot` method of a [service provider](/docs/{{version}}/providers). For example, we may use the `AppServiceProvider` that is included with Laravel:\n\n```php\n<?php\n\nnamespace App\\Providers;\n\nuse Illuminate\\Support\\Facades\\Queue;\nuse Illuminate\\Support\\ServiceProvider;\nuse Illuminate\\Queue\\Events\\JobProcessed;\nuse Illuminate\\Queue\\Events\\JobProcessing;\n\nclass AppServiceProvider extends ServiceProvider\n{\n    /**\n     * Register any application services.\n     */\n    public function register(): void\n    {\n        // ...\n    }\n\n    /**\n     * Bootstrap any application services.\n     */\n    public function boot(): void\n    {\n        Queue::before(function (JobProcessing $event) {\n            // $event->connectionName\n            // $event->job\n            // $event->job->payload()\n        });\n\n        Queue::after(function (JobProcessed $event) {\n            // $event->connectionName\n            // $event->job\n            // $event->job->payload()\n        });\n    }\n}\n```\n\nUsing the `looping` method on the `Queue` [facade](/docs/{{version}}/facades), you may specify callbacks that execute before the worker attempts to fetch a job from a queue. For example, you might register a closure to rollback any transactions that were left open by a previously failed job:\n\n```php\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\Queue;\n\nQueue::looping(function () {\n    while (DB::transactionLevel() > 0) {\n        DB::rollBack();\n    }\n});\n```\n\n---\n\n## Scheduling Defining Schedules Running Tasks on One Server Naming Single Server Jobs\nScheduling | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/scheduling.md\n\n\n#### Naming Single Server Jobs\n\nSometimes you may need to schedule the same job to be dispatched with different parameters, while still instructing Laravel to run each permutation of the job on a single server. To accomplish this, you may assign each schedule definition a unique name via the `name` method:\n\n```php\nSchedule::job(new CheckUptime('https://laravel.com'))\n    ->name('check_uptime:laravel.com')\n    ->everyFiveMinutes()\n    ->onOneServer();\n\nSchedule::job(new CheckUptime('https://vapor.laravel.com'))\n    ->name('check_uptime:vapor.laravel.com')\n    ->everyFiveMinutes()\n    ->onOneServer();\n```\n\nSimilarly, scheduled closures must be assigned a name if they are intended to be run on one server:\n\n```php\nSchedule::call(fn () => User::resetApiRequestCount())\n    ->name('reset-api-request-count')\n    ->daily()\n    ->onOneServer();\n```\n\n---\n\n## Scheduling Defining Schedules Scheduling Queued Jobs\nScheduling | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/scheduling.md\n\n\n### Scheduling Queued Jobs\n\nThe `job` method may be used to schedule a [queued job](/docs/{{version}}/queues). This method provides a convenient way to schedule queued jobs without using the `call` method to define closures to queue the job:\n\n```php\nuse App\\Jobs\\Heartbeat;\nuse Illuminate\\Support\\Facades\\Schedule;\n\nSchedule::job(new Heartbeat)->everyFiveMinutes();\n```\n\nOptional second and third arguments may be provided to the `job` method which specifies the queue name and queue connection that should be used to queue the job:\n\n```php\nuse App\\Jobs\\Heartbeat;\nuse Illuminate\\Support\\Facades\\Schedule;\n\n// Dispatch the job to the \"heartbeats\" queue on the \"sqs\" connection...\nSchedule::job(new Heartbeat, 'heartbeats', 'sqs')->everyFiveMinutes();\n```\n\n---\n\n## Arch Testing Modifiers `abstracts()`\nArch Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/arch-testing.md\n\n\n### `abstracts()`\n\nThe `abstracts()` modifier allows you to restrict the expectation to only abstract classes.\n\n```php\narch('app')\n    ->expect('App')\n    ->abstracts()\n    ->toImplement(JsonSerializable::class);\n```\n---\n\nIn this section, you have learned how to perform architectural testing, ensuring that your application or library's architecture meets a specified set of architectural requirements. Next, have you ever wondered how to test the performance of your code? Let's explore [Stress Testing](/docs/stress-testing).\n\n---\n\n## Responses Redirects Redirecting to Named Routes Populating Parameters via Eloquent Models\nResponses | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/responses.md\n\n\n#### Populating Parameters via Eloquent Models\n\nIf you are redirecting to a route with an \"ID\" parameter that is being populated from an Eloquent model, you may pass the model itself. The ID will be extracted automatically:\n\n```php\n// For a route with the following URI: /profile/{id}\n\nreturn redirect()->route('profile', [$user]);\n```\n\nIf you would like to customize the value that is placed in the route parameter, you can specify the column in the route parameter definition (`/profile/{id:slug}`) or you can override the `getRouteKey` method on your Eloquent model:\n\n```php\n/**\n * Get the value of the model's route key.\n */\npublic function getRouteKey(): mixed\n{\n    return $this->slug;\n}\n```\n\n---\n\n## Browser Testing waitForKey\nBrowser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/browser-testing.md\n\n\n### waitForKey\n\nThe `waitForKey` method opens the current page URL in the default web browser and waits for a key press:\n\n```php\n$page->waitForKey(); // Useful for debugging\n```\n\n## Debugging tests\n\n\nSometimes you may want to debug your browser tests. Pest provides a convenient way to do this by using the `--debug` option, which makes pest to open the browser window and pause the execution of the test when it fails. You can then inspect the page and see what went wrong.\n\n```bash\n./vendor/bin/pest --debug\n```\n\nOptionally, you can also use the `debug()` method in your test. It will limit execution to this test (like using [`only()`](filtering-tests.md#only)), pause the execution and open the browser window:\n\n```php\n$page->debug();\n```\n\n\n\nYou can also take a screenshot of the current page using the `screenshot()` method. This is useful for visual debugging:\n\nNOTE: If you don't pass the filename, it will use the test name as the filename.\n\n```php\n$page->screenshot();\n$page->screenshot(fullPage: true);\n$page->screenshot(filename: 'custom-name');\n```\n\n\nYou can also take a screenshot of a specific element using the `screenshotElement()` method:\n\n```php\n$page->screenshotElement('#my-element');\n```\n\n\nYou can also use the `tinker()` method to open a Tinker session in the context of the current page. This allows you to interact with the page using PHP code:\n\n```php\n$page->tinker();\n```\n\n\nAfter you can run your tests with the `--headed` option to open the browser window:\n\n```bash\n./vendor/bin/pest --headed\n```\n\nIf you wish to run the tests in a headed mode by default, you can set it in your `Pest.php` configuration file:\n\n```php\npest()->browser()->headed();\n```\n\n## Continuous Integration\n\nYou may refer to Pest's [Continuous Integration](https://pestphp.com/docs/continuous-integration) documentation for more information on how to run your browser tests in a CI environment.\n\nHowever, if you are using GitHub Actions, you need to add the following steps to your workflow file:\n\n```yaml\n    - uses: actions/setup-node@v4\n      with:\n        node-version: lts/*\n\n    - name: Install dependencies\n      run: npm ci\n\n    - name: Install Playwright Browsers\n      run: npx playwright install --with-deps\n```\n\n---\n\nNow, let's dive into architectural testing and how it can benefit your development process. By performing architectural testing, you can evaluate the overall design of your application and identify potential flaws before they become significant issues: [Arch Testing](/docs/arch-testing)\n\n_[Truncated]_"}]


For the next backend slice I’m keeping the scope service-level: refund processing, fulfillment guards/transitions, and bank-transfer confirmation/cancellation. I’ve refreshed the docs for transaction/event/job behavior and will add focused service tests before wiring any UI.


php artisan make:class Services/RefundService --no-interaction


php artisan make:class Services/FulfillmentService --no-interaction


php artisan make:job CancelUnpaidBankTransferOrders --no-interaction


php artisan make:exception InvalidRefundOperationException --no-interaction && php artisan make:exception InvalidFulfillmentOperationException --no-interaction


Output:
Chunk ID: a0b477
Wall time: 0.1765 seconds
Process exited with code 0
Original token count: 19
Output:

   INFO  Class [app/Services/RefundService.php] created successfully.


Output:
Chunk ID: dd1a18
Wall time: 0.1767 seconds
Process exited with code 0
Original token count: 20
Output:

   INFO  Class [app/Services/FulfillmentService.php] created successfully.


Output:
Chunk ID: 8b9c17
Wall time: 0.1698 seconds
Process exited with code 0
Original token count: 22
Output:

   INFO  Job [app/Jobs/CancelUnpaidBankTransferOrders.php] created successfully.


php artisan make:class Services/FulfillmentService --no-interaction

INFO  Class [app/Services/FulfillmentService.php] created successfully.


php artisan make:job CancelUnpaidBankTransferOrders --no-interaction

INFO  Job [app/Jobs/CancelUnpaidBankTransferOrders.php] created successfully.


php artisan make:class Services/RefundService --no-interaction

INFO  Class [app/Services/RefundService.php] created successfully.


Output:
Chunk ID: 41b78c
Wall time: 0.3095 seconds
Process exited with code 0
Original token count: 51
Output:

   INFO  Exception [app/Exceptions/InvalidRefundOperationException.php] created successfully.

   INFO  Exception [app/Exceptions/InvalidFulfillmentOperationException.php] created successfully.


php artisan make:exception InvalidRefundOperationException --no-interaction && php artisan make:exception InvalidFulfillmentOperationException --no-interaction

   INFO  Exception [app/Exceptions/InvalidRefundOperationException.php] created successfully.

   INFO  Exception [app/Exceptions/InvalidFulfillmentOperationException.php] created successfully.


php artisan make:event OrderRefunded --no-interaction && php artisan make:event OrderCancelled --no-interaction


php artisan make:event FulfillmentCreated --no-interaction && php artisan make:event FulfillmentShipped --no-interaction && php artisan make:event FulfillmentDelivered --no-interaction


php artisan make:test --pest Orders/RefundServiceTest --no-interaction


php artisan make:test --pest Orders/FulfillmentServiceTest --no-interaction


php artisan make:test --pest Orders/RefundServiceTest --no-interaction

INFO  Test [tests/Feature/Orders/RefundServiceTest.php] created successfully.


php artisan make:test --pest Orders/FulfillmentServiceTest --no-interaction

INFO  Test [tests/Feature/Orders/FulfillmentServiceTest.php] created successfully.


Output:
Chunk ID: f5cabd
Wall time: 0.3253 seconds
Process exited with code 0
Original token count: 37
Output:

   INFO  Event [app/Events/OrderRefunded.php] created successfully.

   INFO  Event [app/Events/OrderCancelled.php] created successfully.


php artisan make:event OrderRefunded --no-interaction && php artisan make:event OrderCancelled --no-interaction

   INFO  Event [app/Events/OrderRefunded.php] created successfully.

   INFO  Event [app/Events/OrderCancelled.php] created successfully.


Output:
Chunk ID: 2c892f
Wall time: 0.4691 seconds
Process exited with code 0
Original token count: 59
Output:

   INFO  Event [app/Events/FulfillmentCreated.php] created successfully.

   INFO  Event [app/Events/FulfillmentShipped.php] created successfully.

   INFO  Event [app/Events/FulfillmentDelivered.php] created successfully.


Output:
Chunk ID: 2a051e
Wall time: 0.1636 seconds
Process exited with code 0
Original token count: 22
Output:

   INFO  Test [tests/Feature/Orders/RefundServiceTest.php] created successfully.


Output:
Chunk ID: 48335f
Wall time: 0.1614 seconds
Process exited with code 0
Original token count: 23
Output:

   INFO  Test [tests/Feature/Orders/FulfillmentServiceTest.php] created successfully.


php artisan make:event FulfillmentCreated --no-interaction && php artisan make:event FulfillmentShipped --no-interaction && php artisan make:event FulfillmentDelivered --no-interaction

   INFO  Event [app/Events/FulfillmentCreated.php] created successfully.

   INFO  Event [app/Events/FulfillmentShipped.php] created successfully.

   INFO  Event [app/Events/FulfillmentDelivered.php] created successfully.


php artisan make:test --pest Orders/BankTransferOrderTest --no-interaction


Output:
Chunk ID: dd4271
Wall time: 0.0247 seconds
Process exited with code 0
Original token count: 23
Output:

   INFO  Test [tests/Feature/Orders/BankTransferOrderTest.php] created successfully.


php artisan make:test --pest Orders/BankTransferOrderTest --no-interaction

INFO  Test [tests/Feature/Orders/BankTransferOrderTest.php] created successfully.


sed -n '1,220p' app/Jobs/CancelUnpaidBankTransferOrders.php


sed -n '1,120p' app/Services/RefundService.php


sed -n '1,120p' app/Services/FulfillmentService.php


sed -n '1,80p' tests/Feature/Orders/RefundServiceTest.php


sed -n '1,80p' tests/Feature/Orders/RefundServiceTest.php

<?php

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

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


sed -n '1,120p' app/Services/FulfillmentService.php

<?php

namespace App\Services;

class FulfillmentService
{
    /**
     * Create a new class instance.
     */
    public function __construct()
    {
        //
    }
}


sed -n '1,120p' app/Services/RefundService.php

<?php

namespace App\Services;

class RefundService
{
    /**
     * Create a new class instance.
     */
    public function __construct()
    {
        //
    }
}


sed -n '1,220p' app/Jobs/CancelUnpaidBankTransferOrders.php

<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class CancelUnpaidBankTransferOrders implements ShouldQueue
{
    use Queueable;

    /**
     * Create a new job instance.
     */
    public function __construct()
    {
        //
    }

    /**
     * Execute the job.
     */
    public function handle(): void
    {
        //
    }
}


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

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class CancelUnpaidBankTransferOrders implements ShouldQueue
{
    use Queueable;

    /**
     * Create a new job instance.
     */
    public function __construct()
    {
        //
    }

    /**
     * Execute the job.
     */
    public function handle(): void
    {
        //
    }
}


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

namespace App\Services;

class RefundService
{
    /**
     * Create a new class instance.
     */
    public function __construct()
    {
        //
    }
}


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

namespace App\Services;

class FulfillmentService
{
    /**
     * Create a new class instance.
     */
    public function __construct()
    {
        //
    }
}


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

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

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


/bin/zsh
-lc
sed -n '1,160p' routes/console.php

<?php

use App\Jobs\CleanupAbandonedCarts;
use App\Jobs\ExpireAbandonedCheckouts;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;

Artisan::command('inspire', function () {
    $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');

Schedule::job(new ExpireAbandonedCheckouts)->everyFifteenMinutes();
Schedule::job(new CleanupAbandonedCarts)->daily();


Output:
Chunk ID: 4e7287
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 112
Output:
<?php

use App\Jobs\CleanupAbandonedCarts;
use App\Jobs\ExpireAbandonedCheckouts;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;

Artisan::command('inspire', function () {
    $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');

Schedule::job(new ExpireAbandonedCheckouts)->everyFifteenMinutes();
Schedule::job(new CleanupAbandonedCarts)->daily();


The implementation shape is now clear: services validate the operation, mutate order/payment/inventory inside transactions, and dispatch domain events. I’m patching the services plus scheduled bank-transfer cancellation before adding the new tests.


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Exceptions/InvalidRefundOperationException.php
@@
 <?php

 namespace App\Exceptions;

-use Exception;
+use RuntimeException;

-class InvalidRefundOperationException extends Exception
+class InvalidRefundOperationException extends RuntimeException
 {
-    //
+    public static function because(string $reason): self
+    {
+        return new self($reason);
+    }
 }
*** Update File: /Users/fabianwesner/Herd/shop/app/Exceptions/InvalidFulfillmentOperationException.php
@@
 <?php

 namespace App\Exceptions;

-use Exception;
+use RuntimeException;

-class InvalidFulfillmentOperationException extends Exception
+class InvalidFulfillmentOperationException extends RuntimeException
 {
-    //
+    public static function because(string $reason): self
+    {
+        return new self($reason);
+    }
 }
*** Update File: /Users/fabianwesner/Herd/shop/app/Events/OrderRefunded.php
@@
-<?php
-
-namespace App\Events;
-
-use Illuminate\Broadcasting\Channel;
-use Illuminate\Broadcasting\InteractsWithSockets;
-use Illuminate\Broadcasting\PresenceChannel;
-use Illuminate\Broadcasting\PrivateChannel;
-use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
-use Illuminate\Foundation\Events\Dispatchable;
-use Illuminate\Queue\SerializesModels;
-
-class OrderRefunded
-{
-    use Dispatchable, InteractsWithSockets, SerializesModels;
-
-    /**
-     * Create a new event instance.
-     */
-    public function __construct()
-    {
-        //
-    }
-
-    /**
-     * Get the channels the event should broadcast on.
-     *
-     * @return array<int, \Illuminate\Broadcasting\Channel>
-     */
-    public function broadcastOn(): array
-    {
-        return [
-            new PrivateChannel('channel-name'),
-        ];
-    }
-}
+<?php
+
+namespace App\Events;
+
+use App\Models\Order;
+use App\Models\Refund;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+class OrderRefunded
+{
+    use Dispatchable, SerializesModels;
+
+    public function __construct(
+        public Order $order,
+        public Refund $refund,
+    ) {}
+}
*** Update File: /Users/fabianwesner/Herd/shop/app/Events/OrderCancelled.php
@@
-<?php
-
-namespace App\Events;
-
-use Illuminate\Broadcasting\Channel;
-use Illuminate\Broadcasting\InteractsWithSockets;
-use Illuminate\Broadcasting\PresenceChannel;
-use Illuminate\Broadcasting\PrivateChannel;
-use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
-use Illuminate\Foundation\Events\Dispatchable;
-use Illuminate\Queue\SerializesModels;
-
-class OrderCancelled
-{
-    use Dispatchable, InteractsWithSockets, SerializesModels;
-
-    /**
-     * Create a new event instance.
-     */
-    public function __construct()
-    {
-        //
-    }
-
-    /**
-     * Get the channels the event should broadcast on.
-     *
-     * @return array<int, \Illuminate\Broadcasting\Channel>
-     */
-    public function broadcastOn(): array
-    {
-        return [
-            new PrivateChannel('channel-name'),
-        ];
-    }
-}
+<?php
+
+namespace App\Events;
+
+use App\Models\Order;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+class OrderCancelled
+{
+    use Dispatchable, SerializesModels;
+
+    public function __construct(
+        public Order $order,
+    ) {}
+}
*** Update File: /Users/fabianwesner/Herd/shop/app/Events/FulfillmentCreated.php
@@
-<?php
-
-namespace App\Events;
-
-use Illuminate\Broadcasting\Channel;
-use Illuminate\Broadcasting\InteractsWithSockets;
-use Illuminate\Broadcasting\PresenceChannel;
-use Illuminate\Broadcasting\PrivateChannel;
-use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
-use Illuminate\Foundation\Events\Dispatchable;
-use Illuminate\Queue\SerializesModels;
-
-class FulfillmentCreated
-{
-    use Dispatchable, InteractsWithSockets, SerializesModels;
-
-    /**
-     * Create a new event instance.
-     */
-    public function __construct()
-    {
-        //
-    }
-
-    /**
-     * Get the channels the event should broadcast on.
-     *
-     * @return array<int, \Illuminate\Broadcasting\Channel>
-     */
-    public function broadcastOn(): array
-    {
-        return [
-            new PrivateChannel('channel-name'),
-        ];
-    }
-}
+<?php
+
+namespace App\Events;
+
+use App\Models\Fulfillment;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+class FulfillmentCreated
+{
+    use Dispatchable, SerializesModels;
+
+    public function __construct(
+        public Fulfillment $fulfillment,
+    ) {}
+}
*** Update File: /Users/fabianwesner/Herd/shop/app/Events/FulfillmentShipped.php
@@
-<?php
-
-namespace App\Events;
-
-use Illuminate\Broadcasting\Channel;
-use Illuminate\Broadcasting\InteractsWithSockets;
-use Illuminate\Broadcasting\PresenceChannel;
-use Illuminate\Broadcasting\PrivateChannel;
-use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
-use Illuminate\Foundation\Events\Dispatchable;
-use Illuminate\Queue\SerializesModels;
-
-class FulfillmentShipped
-{
-    use Dispatchable, InteractsWithSockets, SerializesModels;
-
-    /**
-     * Create a new event instance.
-     */
-    public function __construct()
-    {
-        //
-    }
-
-    /**
-     * Get the channels the event should broadcast on.
-     *
-     * @return array<int, \Illuminate\Broadcasting\Channel>
-     */
-    public function broadcastOn(): array
-    {
-        return [
-            new PrivateChannel('channel-name'),
-        ];
-    }
-}
+<?php
+
+namespace App\Events;
+
+use App\Models\Fulfillment;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+class FulfillmentShipped
+{
+    use Dispatchable, SerializesModels;
+
+    public function __construct(
+        public Fulfillment $fulfillment,
+    ) {}
+}
*** Update File: /Users/fabianwesner/Herd/shop/app/Events/FulfillmentDelivered.php
@@
-<?php
-
-namespace App\Events;
-
-use Illuminate\Broadcasting\Channel;
-use Illuminate\Broadcasting\InteractsWithSockets;
-use Illuminate\Broadcasting\PresenceChannel;
-use Illuminate\Broadcasting\PrivateChannel;
-use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
-use Illuminate\Foundation\Events\Dispatchable;
-use Illuminate\Queue\SerializesModels;
-
-class FulfillmentDelivered
-{
-    use Dispatchable, InteractsWithSockets, SerializesModels;
-
-    /**
-     * Create a new event instance.
-     */
-    public function __construct()
-    {
-        //
-    }
-
-    /**
-     * Get the channels the event should broadcast on.
-     *
-     * @return array<int, \Illuminate\Broadcasting\Channel>
-     */
-    public function broadcastOn(): array
-    {
-        return [
-            new PrivateChannel('channel-name'),
-        ];
-    }
-}
+<?php
+
+namespace App\Events;
+
+use App\Models\Fulfillment;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+class FulfillmentDelivered
+{
+    use Dispatchable, SerializesModels;
+
+    public function __construct(
+        public Fulfillment $fulfillment,
+    ) {}
+}
*** End Patch


Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/app/Exceptions/InvalidRefundOperationException.php
M /Users/fabianwesner/Herd/shop/app/Exceptions/InvalidFulfillmentOperationException.php
M /Users/fabianwesner/Herd/shop/app/Events/OrderRefunded.php
M /Users/fabianwesner/Herd/shop/app/Events/OrderCancelled.php
M /Users/fabianwesner/Herd/shop/app/Events/FulfillmentCreated.php
M /Users/fabianwesner/Herd/shop/app/Events/FulfillmentShipped.php
M /Users/fabianwesner/Herd/shop/app/Events/FulfillmentDelivered.php

@@ -4,7 +4,3 @@

-use Illuminate\Broadcasting\Channel;
-use Illuminate\Broadcasting\InteractsWithSockets;
-use Illuminate\Broadcasting\PresenceChannel;
-use Illuminate\Broadcasting\PrivateChannel;
-use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
+use App\Models\Fulfillment;
 use Illuminate\Foundation\Events\Dispatchable;
@@ -14,23 +10,7 @@
 {
-    use Dispatchable, InteractsWithSockets, SerializesModels;
+    use Dispatchable, SerializesModels;

-    /**
-     * Create a new event instance.
-     */
-    public function __construct()
-    {
-        //
-    }
-
-    /**
-     * Get the channels the event should broadcast on.
-     *
-     * @return array<int, \Illuminate\Broadcasting\Channel>
-     */
-    public function broadcastOn(): array
-    {
-        return [
-            new PrivateChannel('channel-name'),
-        ];
-    }
+    public function __construct(
+        public Fulfillment $fulfillment,
+    ) {}
 }

@@ -4,7 +4,10 @@

-use Exception;
+use RuntimeException;

-class InvalidRefundOperationException extends Exception
+class InvalidRefundOperationException extends RuntimeException
 {
-    //
+    public static function because(string $reason): self
+    {
+        return new self($reason);
+    }
 }

@@ -4,7 +4,4 @@

-use Illuminate\Broadcasting\Channel;
-use Illuminate\Broadcasting\InteractsWithSockets;
-use Illuminate\Broadcasting\PresenceChannel;
-use Illuminate\Broadcasting\PrivateChannel;
-use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
+use App\Models\Order;
+use App\Models\Refund;
 use Illuminate\Foundation\Events\Dispatchable;
@@ -14,23 +11,8 @@
 {
-    use Dispatchable, InteractsWithSockets, SerializesModels;
+    use Dispatchable, SerializesModels;

-    /**
-     * Create a new event instance.
-     */
-    public function __construct()
-    {
-        //
-    }
-
-    /**
-     * Get the channels the event should broadcast on.
-     *
-     * @return array<int, \Illuminate\Broadcasting\Channel>
-     */
-    public function broadcastOn(): array
-    {
-        return [
-            new PrivateChannel('channel-name'),
-        ];
-    }
+    public function __construct(
+        public Order $order,
+        public Refund $refund,
+    ) {}
 }

@@ -4,7 +4,3 @@

-use Illuminate\Broadcasting\Channel;
-use Illuminate\Broadcasting\InteractsWithSockets;
-use Illuminate\Broadcasting\PresenceChannel;
-use Illuminate\Broadcasting\PrivateChannel;
-use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
+use App\Models\Order;
 use Illuminate\Foundation\Events\Dispatchable;
@@ -14,23 +10,7 @@
 {
-    use Dispatchable, InteractsWithSockets, SerializesModels;
+    use Dispatchable, SerializesModels;

-    /**
-     * Create a new event instance.
-     */
-    public function __construct()
-    {
-        //
-    }
-
-    /**
-     * Get the channels the event should broadcast on.
-     *
-     * @return array<int, \Illuminate\Broadcasting\Channel>
-     */
-    public function broadcastOn(): array
-    {
-        return [
-            new PrivateChannel('channel-name'),
-        ];
-    }
+    public function __construct(
+        public Order $order,
+    ) {}
 }

@@ -4,7 +4,10 @@

-use Exception;
+use RuntimeException;

-class InvalidFulfillmentOperationException extends Exception
+class InvalidFulfillmentOperationException extends RuntimeException
 {
-    //
+    public static function because(string $reason): self
+    {
+        return new self($reason);
+    }
 }


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/app/Exceptions/InvalidRefundOperationException.php\nM /Users/fabianwesner/Herd/shop/app/Exceptions/InvalidFulfillmentOperationException.php\nM /Users/fabianwesner/Herd/shop/app/Events/OrderRefunded.php\nM /Users/fabianwesner/Herd/shop/app/Events/OrderCancelled.php\nM /Users/fabianwesner/Herd/shop/app/Events/FulfillmentCreated.php\nM /Users/fabianwesner/Herd/shop/app/Events/FulfillmentShipped.php\nM /Users/fabianwesner/Herd/shop/app/Events/FulfillmentDelivered.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Services/FulfillmentService.php
@@
 <?php

 namespace App\Services;

+use App\Enums\FinancialStatus;
+use App\Enums\FulfillmentShipmentStatus;
+use App\Enums\FulfillmentStatus;
+use App\Enums\OrderStatus;
+use App\Events\FulfillmentCreated;
+use App\Events\FulfillmentDelivered;
+use App\Events\FulfillmentShipped;
+use App\Exceptions\InvalidFulfillmentOperationException;
+use App\Models\Fulfillment;
+use App\Models\FulfillmentLine;
+use App\Models\Order;
+use App\Models\OrderLine;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+
 class FulfillmentService
 {
     /**
-     * Create a new class instance.
+     * @param  array<int|string, int>  $lines
+     * @param  array<string, mixed>  $trackingData
      */
-    public function __construct()
+    public function create(Order $order, array $lines, array $trackingData = []): Fulfillment
     {
-        //
+        return DB::transaction(function () use ($order, $lines, $trackingData): Fulfillment {
+            $order = $this->freshOrder($order);
+            $this->assertCanFulfill($order);
+
+            $requestedLines = $this->normalizeLines($lines);
+
+            if ($requestedLines->isEmpty()) {
+                throw InvalidFulfillmentOperationException::because('At least one fulfillment line is required.');
+            }
+
+            $requestedLines->each(function (int $quantity, int $lineId) use ($order): void {
+                $line = $order->lines->firstWhere('id', $lineId);
+
+                if (! $line instanceof OrderLine) {
+                    throw InvalidFulfillmentOperationException::because('Fulfillment line does not belong to this order.');
+                }
+
+                $remaining = $line->quantity - $this->fulfilledQuantity($line);
+
+                if ($quantity > $remaining) {
+                    throw InvalidFulfillmentOperationException::because('Fulfillment quantity exceeds the unfulfilled quantity.');
+                }
+            });
+
+            $fulfillment = Fulfillment::query()->create([
+                'order_id' => $order->getKey(),
+                'status' => FulfillmentShipmentStatus::Pending,
+                'tracking_company' => data_get($trackingData, 'tracking_company'),
+                'tracking_number' => data_get($trackingData, 'tracking_number'),
+                'tracking_url' => data_get($trackingData, 'tracking_url'),
+            ]);
+
+            $requestedLines->each(function (int $quantity, int $lineId) use ($fulfillment): void {
+                $fulfillment->lines()->create([
+                    'order_line_id' => $lineId,
+                    'quantity' => $quantity,
+                ]);
+            });
+
+            $this->updateOrderFulfillmentStatus($order);
+
+            $fulfillment = $fulfillment->refresh()->load('lines.orderLine');
+
+            event(new FulfillmentCreated($fulfillment));
+
+            return $fulfillment;
+        });
+    }
+
+    public function markShipped(Fulfillment $fulfillment): Fulfillment
+    {
+        return DB::transaction(function () use ($fulfillment): Fulfillment {
+            $fulfillment = $this->freshFulfillment($fulfillment);
+
+            if ($fulfillment->status !== FulfillmentShipmentStatus::Pending) {
+                throw InvalidFulfillmentOperationException::because('Only pending fulfillments can be marked as shipped.');
+            }
+
+            $fulfillment->forceFill([
+                'status' => FulfillmentShipmentStatus::Shipped,
+                'shipped_at' => now(),
+            ])->save();
+
+            $fulfillment = $fulfillment->refresh()->load('lines.orderLine');
+
+            event(new FulfillmentShipped($fulfillment));
+
+            return $fulfillment;
+        });
+    }
+
+    public function markDelivered(Fulfillment $fulfillment): Fulfillment
+    {
+        return DB::transaction(function () use ($fulfillment): Fulfillment {
+            $fulfillment = $this->freshFulfillment($fulfillment);
+
+            if ($fulfillment->status !== FulfillmentShipmentStatus::Shipped) {
+                throw InvalidFulfillmentOperationException::because('Only shipped fulfillments can be marked as delivered.');
+            }
+
+            $fulfillment->forceFill([
+                'status' => FulfillmentShipmentStatus::Delivered,
+                'delivered_at' => now(),
+            ])->save();
+
+            $fulfillment = $fulfillment->refresh()->load('lines.orderLine');
+
+            event(new FulfillmentDelivered($fulfillment));
+
+            return $fulfillment;
+        });
+    }
+
+    public function autoFulfillDigital(Order $order): ?Fulfillment
+    {
+        return DB::transaction(function () use ($order): ?Fulfillment {
+            $order = $this->freshOrder($order);
+
+            if ($order->fulfillments()->exists() || $order->lines->isEmpty()) {
+                return null;
+            }
+
+            $allDigital = $order->lines->every(fn (OrderLine $line): bool => $line->variant?->requires_shipping === false);
+
+            if (! $allDigital) {
+                return null;
+            }
+
+            $fulfillment = Fulfillment::query()->create([
+                'order_id' => $order->getKey(),
+                'status' => FulfillmentShipmentStatus::Delivered,
+                'shipped_at' => now(),
+                'delivered_at' => now(),
+            ]);
+
+            $order->lines->each(function (OrderLine $line) use ($fulfillment): void {
+                $fulfillment->lines()->create([
+                    'order_line_id' => $line->getKey(),
+                    'quantity' => $line->quantity,
+                ]);
+            });
+
+            $order->forceFill([
+                'status' => OrderStatus::Fulfilled,
+                'fulfillment_status' => FulfillmentStatus::Fulfilled,
+            ])->save();
+
+            $fulfillment = $fulfillment->refresh()->load('lines.orderLine');
+
+            event(new FulfillmentCreated($fulfillment));
+            event(new FulfillmentDelivered($fulfillment));
+
+            return $fulfillment;
+        });
+    }
+
+    private function freshOrder(Order $order): Order
+    {
+        return Order::withoutGlobalScopes()
+            ->with([
+                'lines.variant' => fn ($query) => $query->withoutGlobalScopes(),
+                'fulfillments.lines',
+            ])
+            ->whereKey($order->getKey())
+            ->lockForUpdate()
+            ->firstOrFail();
+    }
+
+    private function freshFulfillment(Fulfillment $fulfillment): Fulfillment
+    {
+        return Fulfillment::query()
+            ->with('lines.orderLine')
+            ->whereKey($fulfillment->getKey())
+            ->lockForUpdate()
+            ->firstOrFail();
+    }
+
+    private function assertCanFulfill(Order $order): void
+    {
+        if (! in_array($order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true)) {
+            throw InvalidFulfillmentOperationException::because('Fulfillment cannot be created until payment is confirmed.');
+        }
+    }
+
+    /**
+     * @param  array<int|string, int>  $lines
+     * @return Collection<int, int>
+     */
+    private function normalizeLines(array $lines): Collection
+    {
+        return collect($lines)
+            ->mapWithKeys(fn (mixed $quantity, int|string $lineId): array => [(int) $lineId => (int) $quantity])
+            ->filter(fn (int $quantity): bool => $quantity > 0);
+    }
+
+    private function fulfilledQuantity(OrderLine $line): int
+    {
+        return (int) FulfillmentLine::query()
+            ->where('order_line_id', $line->getKey())
+            ->sum('quantity');
+    }
+
+    private function updateOrderFulfillmentStatus(Order $order): void
+    {
+        $allFulfilled = $order->lines->every(function (OrderLine $line): bool {
+            return $this->fulfilledQuantity($line) >= $line->quantity;
+        });
+
+        $order->forceFill([
+            'status' => $allFulfilled ? OrderStatus::Fulfilled : $order->status,
+            'fulfillment_status' => $allFulfilled ? FulfillmentStatus::Fulfilled : FulfillmentStatus::Partial,
+        ])->save();
     }
 }
*** Update File: /Users/fabianwesner/Herd/shop/app/Services/RefundService.php
@@
 <?php

 namespace App\Services;

+use App\Enums\FinancialStatus;
+use App\Enums\OrderStatus;
+use App\Enums\PaymentStatus;
+use App\Enums\RefundStatus;
+use App\Events\OrderRefunded;
+use App\Exceptions\InvalidRefundOperationException;
+use App\Models\InventoryItem;
+use App\Models\Order;
+use App\Models\OrderLine;
+use App\Models\Payment;
+use App\Models\Refund;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+
 class RefundService
 {
     /**
-     * Create a new class instance.
+     * @param  array{amount?: int, lines?: array<int|string, int>, reason?: string|null, restock?: bool}  $request
      */
-    public function __construct()
+    public function __construct(
+        private readonly PaymentService $payments,
+        private readonly InventoryService $inventory,
+    ) {}
+
+    public function process(Order $order, array $request = []): Refund
     {
-        //
+        return DB::transaction(function () use ($order, $request): Refund {
+            $order = $this->freshOrder($order);
+            $payment = $this->refundablePayment($order);
+            $refundable = $this->refundableAmount($order);
+            $lineQuantities = $this->lineQuantities($order, $request['lines'] ?? []);
+            $amount = $this->refundAmount($order, $request, $lineQuantities, $refundable);
+
+            $result = $this->payments->refund($payment, $amount);
+
+            if (! $result->success) {
+                throw InvalidRefundOperationException::because($result->errorMessage ?? 'Refund could not be processed.');
+            }
+
+            $refund = $order->refunds()->create([
+                'payment_id' => $payment->getKey(),
+                'amount' => $amount,
+                'reason' => data_get($request, 'reason'),
+                'status' => $result->status,
+                'provider_refund_id' => $result->referenceId,
+            ]);
+
+            if ((bool) data_get($request, 'restock', false)) {
+                $this->restock($order, $lineQuantities);
+            }
+
+            $this->updateOrderFinancialStatus($order, $payment);
+
+            $order = $order->refresh();
+            $refund = $refund->refresh();
+
+            event(new OrderRefunded($order, $refund));
+
+            return $refund;
+        });
+    }
+
+    private function freshOrder(Order $order): Order
+    {
+        return Order::withoutGlobalScopes()
+            ->with(['lines', 'payments', 'refunds'])
+            ->whereKey($order->getKey())
+            ->lockForUpdate()
+            ->firstOrFail();
+    }
+
+    private function refundablePayment(Order $order): Payment
+    {
+        $payment = $order->payments
+            ->where('status', PaymentStatus::Captured)
+            ->sortByDesc('id')
+            ->first();
+
+        if (! $payment instanceof Payment) {
+            throw InvalidRefundOperationException::because('Order does not have a captured payment to refund.');
+        }
+
+        return $payment;
+    }
+
+    private function refundableAmount(Order $order): int
+    {
+        $refunded = $order->refunds
+            ->reject(fn (Refund $refund): bool => $refund->status === RefundStatus::Failed)
+            ->sum('amount');
+
+        return $order->total_amount - $refunded;
+    }
+
+    /**
+     * @param  array<string, mixed>  $request
+     * @param  Collection<int, int>  $lineQuantities
+     */
+    private function refundAmount(Order $order, array $request, Collection $lineQuantities, int $refundable): int
+    {
+        $amount = array_key_exists('amount', $request)
+            ? (int) $request['amount']
+            : ($lineQuantities->isNotEmpty() ? $this->lineRefundAmount($order, $lineQuantities) : $refundable);
+
+        if ($amount <= 0) {
+            throw InvalidRefundOperationException::because('Refund amount must be greater than zero.');
+        }
+
+        if ($amount > $refundable) {
+            throw InvalidRefundOperationException::because('Refund amount exceeds the remaining refundable amount.');
+        }
+
+        return $amount;
+    }
+
+    /**
+     * @param  array<int|string, int>  $lines
+     * @return Collection<int, int>
+     */
+    private function lineQuantities(Order $order, array $lines): Collection
+    {
+        return collect($lines)
+            ->mapWithKeys(fn (mixed $quantity, int|string $lineId): array => [(int) $lineId => (int) $quantity])
+            ->filter(fn (int $quantity): bool => $quantity > 0)
+            ->each(function (int $quantity, int $lineId) use ($order): void {
+                $line = $order->lines->firstWhere('id', $lineId);
+
+                if (! $line instanceof OrderLine) {
+                    throw InvalidRefundOperationException::because('Refund line does not belong to this order.');
+                }
+
+                if ($quantity > $line->quantity) {
+                    throw InvalidRefundOperationException::because('Refund quantity exceeds the ordered quantity.');
+                }
+            });
+    }
+
+    /**
+     * @param  Collection<int, int>  $lineQuantities
+     */
+    private function lineRefundAmount(Order $order, Collection $lineQuantities): int
+    {
+        return $lineQuantities->sum(function (int $quantity, int $lineId) use ($order): int {
+            $line = $order->lines->firstWhere('id', $lineId);
+
+            if (! $line instanceof OrderLine) {
+                return 0;
+            }
+
+            return (int) round($line->total_amount / $line->quantity * $quantity);
+        });
+    }
+
+    /**
+     * @param  Collection<int, int>  $lineQuantities
+     */
+    private function restock(Order $order, Collection $lineQuantities): void
+    {
+        $linesToRestock = $lineQuantities->isNotEmpty()
+            ? $lineQuantities
+            : $order->lines->mapWithKeys(fn (OrderLine $line): array => [$line->getKey() => $line->quantity]);
+
+        $linesToRestock->each(function (int $quantity, int $lineId) use ($order): void {
+            $line = $order->lines->firstWhere('id', $lineId);
+
+            if (! $line instanceof OrderLine || $line->variant_id === null) {
+                return;
+            }
+
+            $item = InventoryItem::withoutGlobalScopes()
+                ->where('variant_id', $line->variant_id)
+                ->first();
+
+            if ($item instanceof InventoryItem) {
+                $this->inventory->restock($item, $quantity);
+            }
+        });
+    }
+
+    private function updateOrderFinancialStatus(Order $order, Payment $payment): void
+    {
+        $totalRefunded = $order->refunds()
+            ->where('status', '!=', RefundStatus::Failed)
+            ->sum('amount');
+
+        if ($totalRefunded >= $order->total_amount) {
+            $order->forceFill([
+                'status' => OrderStatus::Refunded,
+                'financial_status' => FinancialStatus::Refunded,
+            ])->save();
+
+            $payment->forceFill([
+                'status' => PaymentStatus::Refunded,
+            ])->save();
+
+            return;
+        }
+
+        $order->forceFill([
+            'financial_status' => FinancialStatus::PartiallyRefunded,
+        ])->save();
     }
 }
*** End Patch