> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# Macroable trait

> 說明如何使用 Illuminate\Support\Traits\Macroable trait 為既有的 Laravel 類別新增自訂方法。

## 什麼是 Macroable trait

`Macroable` trait 是可在不修改類別的情況下，事後動態新增方法的機制。Laravel 許多核心類別皆使用此 trait，因此可以在不修改核心程式碼的前提下擴充功能。

trait 的實體位於 `Illuminate\Support\Traits\Macroable`。內部將已註冊的 macro 儲存於靜態屬性 `$macros`，並透過 `__call` / `__callStatic` 魔術方法呼叫。

## 使用 Macroable 的類別

Laravel 中有許多支援 Macroable 的類別。

| 類別                                     | 用途               |
| -------------------------------------- | ---------------- |
| `Illuminate\Support\Collection`        | Collection 操作    |
| `Illuminate\Support\Str`               | 字串操作             |
| `Illuminate\Support\Arr`               | 陣列操作             |
| `Illuminate\Http\Request`              | HTTP 請求          |
| `Illuminate\Http\Response`             | HTTP 回應          |
| `Illuminate\Routing\Router`            | Router           |
| `Illuminate\Routing\ResponseFactory`   | Response Factory |
| `Illuminate\Database\Schema\Blueprint` | Schema Builder   |
| `Illuminate\Pipeline\Pipeline`         | Pipeline         |
| `Illuminate\Testing\TestResponse`      | Test Response    |

## macro() — 新增方法

`macro()` 的第 1 個引數為方法名稱，第 2 個引數為 closure。

```php theme={null}
use Illuminate\Support\Collection;

Collection::macro('toSentence', function (string $separator = '、') {
    /** @var Collection $this */
    return $this->implode($separator);
});

$result = collect(['蘋果', '橘子', '葡萄'])->toSentence();
// '蘋果、橘子、葡萄'
```

Closure 內的 `$this` 會被綁定至呼叫該 macro 的實例。因此可以直接存取類別的屬性或方法。

## mixin() — 一次新增多個方法

若要一次註冊多個 macro，可使用 `mixin()`。Mixin 類別的 `public` / `protected` 方法都會被註冊為 macro。

```php theme={null}
namespace App\Mixins;

class CollectionMixin
{
    public function toCsv(): Closure
    {
        return function (string $separator = ',') {
            /** @var \Illuminate\Support\Collection $this */
            return $this->map(function ($item) use ($separator) {
                return is_array($item) ? implode($separator, $item) : $item;
            })->implode("\n");
        };
    }

    public function filterEmpty(): Closure
    {
        return function () {
            /** @var \Illuminate\Support\Collection $this */
            return $this->filter(fn ($item) => ! empty($item))->values();
        };
    }

    public function groupByFirst(): Closure
    {
        return function (string $key) {
            /** @var \Illuminate\Support\Collection $this */
            return $this->groupBy(fn ($item) => $item[$key][0] ?? '');
        };
    }
}
```

<Info>
  `mixin()` 的方法必須回傳將被註冊為 macro 的 closure。方法自身的回傳值即為 macro 的實作。
</Info>

## 於服務提供者註冊

Macro 需於應用程式啟動時註冊。`AppServiceProvider` 的 `boot()` 方法為合適之處。

```php theme={null}
namespace App\Providers;

use App\Mixins\CollectionMixin;
use Illuminate\Support\Collection;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Illuminate\Http\Request;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // 註冊單一 macro
        Collection::macro('toSentence', function (string $separator = '、') {
            return $this->implode($separator);
        });

        // 一次註冊 mixin
        Collection::mixin(new CollectionMixin);

        // 對 Str 加入 macro
        Str::macro('initials', function (string $name) {
            return collect(explode(' ', $name))
                ->map(fn ($word) => strtoupper($word[0]))
                ->implode('');
        });
    }
}
```

## 實務使用情境

### 擴充 Collection

為 Collection 新增自訂方法是最常見的使用案例。

```php theme={null}
// 數值 Collection 的統計方法
Collection::macro('median', function () {
    $sorted = $this->sort()->values();
    $count = $sorted->count();

    if ($count === 0) {
        return null;
    }

    $middle = (int) floor($count / 2);

    if ($count % 2 === 0) {
        return ($sorted->get($middle - 1) + $sorted->get($middle)) / 2;
    }

    return $sorted->get($middle);
});

$median = collect([3, 1, 4, 1, 5, 9, 2, 6])->median();
// 3.5

// 回傳附有分頁資訊的 Collection 的 macro
Collection::macro('paginateArray', function (int $perPage = 15, int $page = 1) {
    return $this->slice(($page - 1) * $perPage, $perPage)->values();
});
```

### 擴充 Str 類別

```php theme={null}
// 中文字元計數（支援多位元組）
Str::macro('mbLength', function (string $value) {
    return mb_strlen($value, 'UTF-8');
});

// 將 snake case 轉為點記法
Str::macro('toDotNotation', function (string $value) {
    return str_replace('_', '.', $value);
});

$length = Str::mbLength('你好'); // 2
$dot = Str::toDotNotation('user_profile_name'); // 'user.profile.name'
```

### 擴充 Request 類別

```php theme={null}
use Illuminate\Http\Request;

Request::macro('isFromMobile', function () {
    /** @var Request $this */
    $userAgent = $this->userAgent() ?? '';

    return preg_match('/Mobile|Android|iPhone|iPad/i', $userAgent) === 1;
});

Request::macro('preferredLocale', function (array $available = ['zh-TW', 'en']) {
    /** @var Request $this */
    foreach ($this->getLanguages() as $lang) {
        $short = substr($lang, 0, 2);
        if (in_array($short, $available)) {
            return $short;
        }
    }

    return $available[0] ?? 'en';
});
```

```php theme={null}
// 於 Controller 使用
public function index(Request $request)
{
    if ($request->isFromMobile()) {
        return response()->json($this->getMobileData());
    }

    $locale = $request->preferredLocale(['zh-TW', 'en', 'ja']);
    // ...
}
```

### 擴充 Blueprint（Migration）

若將 Schema 的欄位定義集中 macro 化，可保持一致的 DB 設計。

```php theme={null}
use Illuminate\Database\Schema\Blueprint;

Blueprint::macro('addTimestampsWithTimezone', function () {
    /** @var Blueprint $this */
    $this->timestampsTz();
    $this->softDeletesTz();
});

Blueprint::macro('addUserTracking', function () {
    /** @var Blueprint $this */
    $this->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
    $this->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
});
```

```php theme={null}
// 於 Migration 使用
Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->text('body');
    $table->addTimestampsWithTimezone();
    $table->addUserTracking();
});
```

### 擴充測試 Response

可新增測試專用的斷言方法。

```php theme={null}
use Illuminate\Testing\TestResponse;

TestResponse::macro('assertPaginated', function () {
    /** @var TestResponse $this */
    return $this->assertJsonStructure([
        'data',
        'meta' => ['current_page', 'last_page', 'per_page', 'total'],
        'links' => ['first', 'last', 'prev', 'next'],
    ]);
});

TestResponse::macro('assertApiSuccess', function () {
    /** @var TestResponse $this */
    return $this->assertOk()->assertJsonPath('success', true);
});
```

```php theme={null}
// 於測試使用
$this->getJson('/api/posts')->assertPaginated();
$this->postJson('/api/orders', $data)->assertApiSuccess();
```

## hasMacro() — 檢查 macro 是否存在

```php theme={null}
use Illuminate\Support\Collection;

if (Collection::hasMacro('toSentence')) {
    $result = collect(['a', 'b'])->toSentence();
}
```

## flushMacros() — 重設 macro

於測試中希望重設 macro 時使用。

```php theme={null}
use Illuminate\Support\Collection;

// 於測試內重設
Collection::flushMacros();
```

<Warning>
  `flushMacros()` 會刪除該類別的所有 macro。為維持測試間的獨立性有時會於 `tearDown()` 呼叫，但也會使其他測試中註冊的 macro 消失，需注意。
</Warning>

## 靜態 macro

Macro 不僅能作為實例方法運作，也能作為靜態方法運作。由 `__callStatic` 處理。

```php theme={null}
Str::macro('randomHex', function (int $length = 8) {
    return substr(bin2hex(random_bytes($length)), 0, $length);
});

// 靜態呼叫
$hex = Str::randomHex(16);
```

## 於自訂類別使用 Macroable trait

也可以將 `Macroable` 組入自己的類別。

```php theme={null}
namespace App\Services;

use Illuminate\Support\Traits\Macroable;

class ReportBuilder
{
    use Macroable;

    protected array $sections = [];

    public function addSection(string $name, callable $content): static
    {
        $this->sections[$name] = $content;

        return $this;
    }

    public function build(): array
    {
        return array_map(fn ($fn) => $fn(), $this->sections);
    }
}
```

```php theme={null}
// 於服務提供者擴充
ReportBuilder::macro('withSummary', function (string $title) {
    /** @var ReportBuilder $this */
    return $this->addSection('summary', fn () => [
        'title' => $title,
        'generated_at' => now()->toIso8601String(),
    ]);
});

// 使用範例
$report = app(ReportBuilder::class)
    ->withSummary('月報')
    ->addSection('data', fn () => ['rows' => 42])
    ->build();
```

## 內部實作細節

```php theme={null}
// __call 的實作（實例方法呼叫）
public function __call($method, $parameters)
{
    if (! static::hasMacro($method)) {
        throw new BadMethodCallException(sprintf(
            'Method %s::%s does not exist.', static::class, $method
        ));
    }

    $macro = static::$macros[$method];

    if ($macro instanceof Closure) {
        // 以 bindTo 將 $this 綁定至實例
        $macro = $macro->bindTo($this, static::class);
    }

    return $macro(...$parameters);
}
```

Closure 透過 `Closure::bindTo()` 綁定至實例。如此 `$this` 便指向呼叫 macro 的物件。若為非 closure（如 invokable 物件），則不會被綁定。

<Tip>
  若要獲得 IDE 支援，可以用 `@mixin` Doc Block 定義 macro 的註解，或使用 Laravel IdeHelper 套件自動生成 helper 檔案。
</Tip>

## 下一步

<Card title="Pipeline 模式" icon="arrow-right-arrow-left" href="/zh-TW/advanced/pipeline">
  學習如何使用 Pipeline 模式將多個處理步驟串聯組合。
</Card>


## Related topics

- [Fluent 類別](/zh-TW/advanced/fluent.md)
- [Dumpable trait](/zh-TW/advanced/dumpable.md)
- [ForwardsCalls trait](/zh-TW/advanced/forwards-calls.md)
- [tap() helper 與 Tappable trait](/zh-TW/advanced/tap.md)
- [InteractsWithData trait](/zh-TW/advanced/interacts-with-data.md)
