オブジェクトにステータスを、"Workflow"

[Symfony Component Advent Calendar 2022](https://qiita.com/advent-calendar/2022/symfony-component)の20日目の記事です。

## 最初に
[Symfony](https://symfony.com)はPHPのフレームワークのひとつです。しかし、公式サイトの説明文には

> Symfony is a set of PHP Components, a Web Application framework, a Philosophy, and a Community — all working together in harmony.
  （SymfonyはPHPコンポーネントのセットで、Webアプリケーションフレームワークで、哲学、そしてコミュニティです。それらがハーモニーを奏でながら動作しています。）

と書かれている通り、PHPコンポーネントのセットで、たくさんのコンポーネントを提供しており、それらを組み合わせてひとつのフレームワークとして動作しています。Symfonyのコンポーネントは、Symfony上だけで動作するのではなく、他のPHPフレームワークやアプリケーションでも動作している強力なものが揃っています。

今回はそれらの中から、役立ちそうなもの・お薦めしたいものを紹介していきたいと思います。

※記事内ではautoloadのインポートは省略します。


## オブジェクトにステータスを、"Workflow"

[Workflow](https://symfony.com/doc/current/components/workflow.html)は、オブジェクトに**ステータス**を持ち、次のステータスへの遷移を制御したり、確認したりするコンポーネントです。Symfony以外でも動作します。

### インストール

```shell
composer require symfony/workflow
```

### Workflowの概要

そもそもこの`Workflow`ですが、ブログ記事作成を業務委託した場合を想像してもらうとなんとなくわかります。
おそらく、ブログ記事を代行して書く場合、記事には

- 下書き
- レビュー済み
- 公開
- 却下

というステータスがあります。このようなステータスで運用すると


```plantuml

|記事作成者|
start
:記事を下書きする;
|クライアント|
label hoge
:記事をレビューする;
if (記事がOK) then
:記事を公開する;
end
else
:記事を却下する;
endif
|記事作成者|
:下書きに戻して記事を修正する;
goto hoge
```

といったようなフローになると思います。このようなフローの場合、**下書きから公開にすすませるわけにはいきません。**
そこで、この`Workflow`を利用します。

`Workflow`を使うことで、記事オブジェクトにステータスを与え、**どのステータスからどのステータスに遷移させるか**を設定して、
思いがけないステータスの変更を行わないようにします。

なお、`Workflow`ではステータスのことを`Place`、ステータスの遷移のことを`Transition`と呼びます。


### Workflowの設定

フローの設定には`DefinitionBuilder`を使い、その設定と`MarkingStoreInterface`で定義したステータス変更方法をもとにワークフローを作成します。

```php

use Symfony\Component\Workflow\DefinitionBuilder;
use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore;
use Symfony\Component\Workflow\Transition;
use Symfony\Component\Workflow\Workflow;

$definitionBuilder = new DefinitionBuilder();
$definition = $definitionBuilder->addPlaces(['draft', 'reviewed', 'rejected', 'published'])
    ->addTransition(new Transition('to_review', 'draft', 'reviewed')) // 'to_review'という名前で（下書き→レビュー済み）な遷移を作成
    ->addTransition(new Transition('publish', 'reviewed', 'published')) // 'publish'という名前で（レビュー済み→公開）
    ->addTransition(new Transition('reject', 'reviewed', 'rejected')) // 'reject'という名前で（レビュー済み→却下）
    ->build()
;

$singleState = true; // ステータスを1つしか持てないようにする
$property = 'currentState'; // ステータスを保持するプロパティ名
// ステータス変更方法オブジェクト（メソッドで変更）
$marking = new MethodMarkingStore($singleState, $property);
$workflow = new Workflow($definition, $marking);
```

### ワークフローの適用と利用

ワークフローの適用と取得`Registry`オブジェクトを利用します。`addWorkflow()`メソッドを使い、クラスとワークフローを紐づけることで適用されます。
`get()`メソッドでオブジェクトを渡せば、紐づいている`Workflow`オブジェクトを取得できます。


ワークフローの利用は`Workflow`オブジェクトを利用します。`can()`メソッドは指定した遷移が可能かチェックします。遷移可能な場合、`apply()`メソッドを呼ぶことで、指定した遷移を行いステータスを変更します。
`getEnabledTransitions()`メソッドでは遷移可能な遷移を全て取得します。


```php

use App\Entity\BlogPost;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\SupportStrategy\InstanceOfSupportStrategy;


... 上記の設定処理

// ワークフローの適用
$registry = new Registry();
// BlogPostクラスに設定したワークフローを適用
$registry->addWorkflow($workflow, new InstanceOfSupportStrategy(BlogPost::class));


// ワークフローの利用
$blogPost = new BlogPost();
// 設定されたワークフローを取得
$workflow = $registry->get($blogPost);

// 'publish'に遷移可能かチェック
$workflow->can($blogPost, 'publish'); // true

// 遷移'to_review'を行い、ステータスを'reviewed'に変更
$workflow->apply($blogPost, 'to_review'); 

// 遷移可能なものを全て取得
$transitions = $workflow->getEnabledTransisions($blogPost); // ['publish', 'reject']
```
:::note warn
DBに自動保存されるわけではないので、自分で保存処理を行う必要があります。
:::

### Symfonyの場合

Symfonyの場合、まず設定は`workflow.yaml`に記載できます。

```yaml:config/packages/workflow.yaml
framework:
    workflows:
        blog_publishing: # 設定名
            type: 'workflow'
            audit_trail:
                enabled: true
            marking_store:
                type: 'method'
                property: 'currentPlace'
            supports:
                - App\Entity\BlogPost
            initial_marking: draft
            places:
                - draft
                - reviewed
                - rejected
                - published
            transitions:
                to_review:
                    from: draft
                    to:   reviewed
                publish:
                    from: reviewed
                    to:   published
                reject:
                    from: reviewed
                    to:   rejected
```

利用する場合は、`yamlで設定した設定名+Workflow`をキャメルケースした変数名で`WorkflowInterface`をDIするようにしておけば、オートワイヤリングで勝手に設定された`Workflow`オブジェクトをセットしてくれます。

```php

class SomeClass
{
    public function __construct(
        private readonly WorkflowInterface $blogPublishingWorkflow // blog_publishing + Wofkflow でキャメルケースに
    )
    {
    }

    public function someMethod(BlogPost $blogPost)
    {
        $this->blogPublishingWofkflow->apply($blogPost, 'to_review');
    }
}
```

#### Twigで利用する

[Twig]()をインストールしている場合は、Twig上で**権限チェック**が利用できます。

```twig
{# オブジェクトが指定の遷移ができるかチェック #}
{% if workflow_can(post, 'publish') %}
    <a href="...">公開する</a>
{% endif %}
{% if workflow_can(post, 'reject') %}
    <a href="...">却下する</a>
{% endif %}

{# オブジェクトがreviewdかチェック #}
{% if workflow_has_marked_place(post, 'reviewed') %}
    <p>レビュー済み</p>
{% endif %}
```

## まとめ

今回は`Workflow`を紹介しました。独自にステータス管理するよりも便利なコンポーネントです。
イベントなども用意されているので、極めればとても安全にステータス管理ができます。