notwarp/laravel-workflow

将 Symfony Workflow 组件集成到 Laravel 中。

资助包维护!
lucaterribili


README

这是从 zerodahero/laravel-workflow 分支出来的。我对这个包的需求比其他包能够维护的更前沿。对 brexis 为原始工作和适应性所做的贡献表示巨大的敬意。

在 Laravel 中使用 Symfony Workflow 组件

安装

composer require lucaterribili/laravel-workflow

Laravel 支持

从 v3 升级到 v4

更改是对 PHP 和 Laravel 版本的更改,本版本仅支持 PHP 8.0、8.1 和 Laravel 9。如果您需要使用旧版本,请从 3.4 版本开始。

从 v2 升级到 v3

从 v2 升级到 v3 的最大变化是依赖项。为了匹配 Symfony v5 组件,Laravel 版本提升到 v7。如果您使用的是 Laravel v6 或更早版本,应继续使用此包的 v2 版本。

为了匹配 Symfony v5 工作流组件的更改,"arguments" 配置选项已更改为 "property"。这描述了工作流关联的模型上的属性(在大多数情况下,您可以简单地将 "arguments" 键名更改为 "property",并将其设置为字符串而不是之前的数组)。

此外,"initial_place" 键已更改为 "initial_places",以与 Symfony 组件保持一致。

非包发现

如果您不使用包发现

config/app.php 中的 providers 数组中添加 ServiceProvider

<?php

'providers' => [
    ...
    LucaTerribili\LaravelWorkflow\WorkflowServiceProvider::class,

]

Workflow 门面添加到您的 facades 数组中

<?php
    ...
    'Workflow' => LucaTerribili\LaravelWorkflow\Facades\WorkflowFacade::class,

配置

Laravel v < 9.0

发布配置文件

php artisan vendor:publish --provider="LucaTerribili\LaravelWorkflow\WorkflowServiceProvider"

config/workflow.php 中配置您的流程

<?php

// Full workflow, annotated.
return [
    // Name of the workflow is the key
    'straight' => [
        'type' => 'workflow', // or 'state_machine', defaults to 'workflow' if omitted
        // The marking store can be omitted, and will default to 'multiple_state'
        // for workflow and 'single_state' for state_machine if the type is omitted
        'marking_store' => [
            'property' => 'marking', // this is the property on the model, defaults to 'marking'
            'class' => MethodMarkingStore::class, // optional, uses EloquentMethodMarkingStore by default (for Eloquent models)
        ],
        // optional top-level metadata
        'metadata' => [
            // any data
        ],
        'supports' => ['App\BlogPost'], // objects this workflow supports
        // Specifies events to dispatch (only in 'workflow', not 'state_machine')
        // - set `null` to dispatch all events (default, if omitted)
        // - set to empty array (`[]`) to dispatch no events
        // - set to array of events to dispatch only specific events
        // Note that announce will dispatch a guard event on the next transition
        // (if announce isn't dispatched the next transition won't guard until checked/applied)
        'events_to_dispatch' => [
           Symfony\Component\Workflow\WorkflowEvents::ENTER,
           Symfony\Component\Workflow\WorkflowEvents::LEAVE,
           Symfony\Component\Workflow\WorkflowEvents::TRANSITION,
           Symfony\Component\Workflow\WorkflowEvents::ENTERED,
           Symfony\Component\Workflow\WorkflowEvents::COMPLETED,
           Symfony\Component\Workflow\WorkflowEvents::ANNOUNCE,
        ],
        'places' => ['draft', 'review', 'rejected', 'published'],
        'initial_places' => ['draft'], // defaults to the first place if omitted
        'transitions' => [
            'to_review' => [
                'from' => 'draft',
                'to' => 'review',
                // optional transition-level metadata
                'metadata' => [
                    // any data
                ]
            ],
            'publish' => [
                'from' => 'review',
                'to' => 'published'
            ],
            'reject' => [
                'from' => 'review',
                'to' => 'rejected'
            ]
        ],
    ]
];

更简洁的设置(适用于 eloquent 模型的流程)。

<?php

// Simple workflow. Sets type 'workflow', with a 'multiple_state' workflow
// on the 'marking' property of any 'App\BlogPost' model.
return [
    'simple' => [
        'supports' => ['App\BlogPost'], // objects this workflow supports
        'places' => ['draft', 'review', 'rejected', 'published'],
        'transitions' => [
            'to_review' => [
                'from' => 'draft',
                'to' => 'review'
            ],
            'publish' => [
                'from' => 'review',
                'to' => 'published'
            ],
            'reject' => [
                'from' => 'review',
                'to' => 'rejected'
            ]
        ],
    ]
];

如果您使用的是 "multiple_state" 类型的流程(即在您的流程中同时处于多个位置),则需要将支持类/Eloquent 模型中的标记转换为数组。有关更多信息,请参阅 Laravel 文档

您还可以添加元数据,类似于 Symfony 实现(注意:它不是以与 Symfony 实现相同的方式收集的,但应该可以正常工作。如果这不是这种情况,请提交拉取请求或问题。)

<?php

return [
    'straight' => [
        'type' => 'workflow', // or 'state_machine'
        'metadata' => [
            'title' => 'Blog Publishing Workflow',
        ],
        'supports' => ['App\BlogPost'],
        'places' => [
            'draft' => [
                'metadata' => [
                    'max_num_of_words' => 500,
                ]
            ],
            'review',
            'rejected',
            'published'
        ],
        'transitions' => [
            'to_review' => [
                'from' => 'draft',
                'to' => 'review',
                'metadata' => [
                    'priority' => 0.5,
                ]
            ],
            'publish' => [
                'from' => 'review',
                'to' => 'published'
            ],
            'reject' => [
                'from' => 'review',
                'to' => 'rejected'
            ]
        ],
    ]
];

Laravel v >= 9.*

从 Laravel 9 开始,我们不使用配置。您需要将工作流程存储在数据库中。我们有两个表:Workflow 和 Transitions

模型在包内,但您可以覆盖这些更改配置文件

<?php

return [
    'models' => [
        'workflow' => LucaTerribili\LaravelWorkflow\Models\Workflow::class,
        'transition' => LucaTerribili\LaravelWorkflow\Models\Transition::class,
    ],
];

Workflow 表的记录示例

$workflows = array(
array('id' => '1','name' => 'MacroTicket a progetto','supports' => '["App\\\\Models\\\\MacroTicketProject"]','places' => '[{"name": "unplannable", "sort": 0, "label": "Non pianificabile"}, {"name": "waiting_plane", "sort": 1, "label": "In attesa pianificazione"}, {"name": "new_plane", "sort": 2, "label": "Da ripianificare"}, {"name": "waiting_plane_accept", "sort": 3, "label": "In attesa accettazione pianificazione"}, {"name": "planned", "sort": 4, "label": "Pianificato"}, {"name": "approved", "sort": 5, "label": "Approvato"}, {"name": "bonded", "sort": 6, "label": "Vincolato"}, {"name": "partial_migrated", "sort": 7, "label": "Migrato parziale"}, {"name": "tested", "sort": 8, "label": "Collaudato"}, {"name": "deleted", "sort": 9, "label": "Annullato"}]','start_place' => 'unplannable','final_place' => 'tested','last_places' => '["tested", "deleted"]','created_at' => '2022-03-07 11:17:29','updated_at' => '2022-03-07 11:17:29')
);

Transitions 表的记录示例

$transitions = array(
array('id' => '1','workflow_id' => '1','name' => 'to_waiting_plane','label' => 'Pianifica','from' => '["unplannable"]','to' => 'waiting_plane','permission' => 'be.workflow.macro_ticket.waiting_plane','created_at' => '2022-03-07 11:17:29','updated_at' => '2022-03-07 11:17:29'),
array('id' => '2','workflow_id' => '1','name' => 'to_ask_approved','label' => 'Manda in approvazione','from' => '["waiting_plane", "new_plane"]','to' => 'waiting_plane_accept','permission' => 'be.workflow.macro_ticket.waiting_plane_accept','created_at' => '2022-03-07 11:17:29','updated_at' => '2022-03-07 11:17:29'),
array('id' => '3','workflow_id' => '1','name' => 'to_replane','label' => 'Rifiuta','from' => '["waiting_plane_accept"]','to' => 'new_plane','permission' => 'be.workflow.macro_ticket.to_replane','created_at' => '2022-03-07 11:17:29','updated_at' => '2022-03-07 11:17:29'),
array('id' => '4','workflow_id' => '1','name' => 'to_planned','label' => 'Approva pianificazione','from' => '["waiting_plane_accept"]','to' => 'planned','permission' => 'be.workflow.macro_ticket.to_planned','created_at' => '2022-03-07 11:17:29','updated_at' => '2022-03-07 11:17:29'),
array('id' => '5','workflow_id' => '1','name' => 'to_reject','label' => 'Anulla pianificazione','from' => '["planned"]','to' => 'new_plane','permission' => 'be.workflow.macro_ticket.to_reject','created_at' => '2022-03-07 11:17:29','updated_at' => '2022-03-07 11:17:29'),
array('id' => '6','workflow_id' => '1','name' => 'to_approved','label' => 'Approva intervento','from' => '["planned"]','to' => 'approved','permission' => 'be.workflow.macro_ticket.to_approved','created_at' => '2022-03-07 11:17:29','updated_at' => '2022-03-07 11:17:29'),
array('id' => '7','workflow_id' => '1','name' => 'to_bonded','label' => 'Vincola','from' => '["approved"]','to' => 'bonded','permission' => 'be.workflow.macro_ticket.to_bonded','created_at' => '2022-03-07 11:17:29','updated_at' => '2022-03-07 11:17:29'),
array('id' => '8','workflow_id' => '1','name' => 'from_bonded_to_replane','label' => 'Rimuovi vincoli','from' => '["bonded"]','to' => 'new_plane','permission' => 'be.workflow.macro_ticket.to_replane','created_at' => '2022-03-07 11:17:29','updated_at' => '2022-03-07 11:17:29'),
array('id' => '9','workflow_id' => '1','name' => 'delete','label' => 'Annulla','from' => '["unplannable", "waiting_plane", "new_plane", "waiting_plane_accept", "planned", "approved", "bonded", "partial_migrated", "tested"]','to' => 'deleted','permission' => 'be.workflow.macro_ticket.delete','created_at' => '2022-03-07 11:17:29','updated_at' => '2022-03-07 11:17:29'),
array('id' => '10','workflow_id' => '1','name' => 'to_tested','label' => 'Collauda','from' => '["approved"]','to' => 'tested','permission' => 'be.workflow.macro_ticket.to_tested','created_at' => '2022-03-07 11:17:29','updated_at' => '2022-03-07 11:17:29')
);

在支持类中使用 WorkflowTrait

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;
use LucaTerribili\LaravelWorkflow\Traits\WorkflowTrait;

class BlogPost extends Model
{
  use WorkflowTrait;

}

用法

<?php

use App\BlogPost;
use Workflow;

$post = BlogPost::find(1);
$workflow = Workflow::get($post);
// if more than one workflow is defined for the BlogPost class
$workflow = Workflow::get($post, $workflowName);
// or get it directly from the trait
$workflow = $post->workflow_get();
// if more than one workflow is defined for the BlogPost class
$workflow = $post->workflow_get($workflowName);

$workflow->can($post, 'publish'); // False
$workflow->can($post, 'to_review'); // True
$transitions = $workflow->getEnabledTransitions($post);

// Apply a transition
$workflow->apply($post, 'to_review');
$post->save(); // Don't forget to persist the state

// Get the workflow directly

// Using the WorkflowTrait
$post->workflow_can('publish'); // True
$post->workflow_can('to_review'); // False

// Get the post transitions
foreach ($post->workflow_transitions() as $transition) {
    echo $transition->getName();
}

// Apply a transition
$post->workflow_apply('publish');
$post->save();

Symfony Workflow 用法

一旦有了底层的 Symfony 工作流组件,您就可以像在 Symfony 中一样做任何您想做的事情。以下提供了一些示例,但请务必查看 Symfony 文档 以更好地理解这里的情况。

<?php

use App\Blogpost;
use Workflow;

$post = BlogPost::find(1);
$workflow = $post->workflow_get();

// Get the current places
$places = $workflow->getMarking($post)->getPlaces();

// Get the definition
$definition = $workflow->getDefinition();

// Get the metadata
$metadata = $workflow->getMetadataStore();
// or get a specific piece of metadata
$workflowMetadata = $workflow->getMetadataStore()->getWorkflowMetadata();
$placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place); // string place name
$transitionMetadata = $workflow->getMetadataStore()->getTransitionMetadata($transition); // transition object
// or by key
$otherPlaceMetadata = $workflow->getMetadataStore()->getMetadata('max_num_of_words', 'draft');

使用事件

此包提供了一组在转换期间触发的事件

    LucaTerribili\LaravelWorkflow\Events\Guard
    LucaTerribili\LaravelWorkflow\Events\Leave
    LucaTerribili\LaravelWorkflow\Events\Transition
    LucaTerribili\LaravelWorkflow\Events\Enter
    LucaTerribili\LaravelWorkflow\Events\Entered

建议您使用 Symfony的点语法风格事件发射,因为这提供了最佳的精度来监听事件,并防止接收同一事件类多次的“相同”事件。工作流组件在每个工作流事件中调度多个事件,如果仅按类名监听,这可能导致“重复”事件的监听。

注意:这些事件在3.1.1版本之前接收Symfony事件,从3.1.1版本开始将接收此包的事件。

<?php

namespace App\Listeners;

use LucaTerribili\LaravelWorkflow\Events\GuardEvent;

class BlogPostWorkflowSubscriber
{
    // ...

    /**
     * Register the listeners for the subscriber.
     *
     * @param  Illuminate\Events\Dispatcher  $events
     */
    public function subscribe($events)
    {
        // can use any of the three formats:
        // workflow.guard
        // workflow.[workflow name].guard
        // workflow.[workflow name].guard.[transition name]
        $events->listen(
            'workflow.straight.guard',
            'App\Listeners\BlogPostWorkflowSubscriber@onGuard'
        );

        // workflow.leave
        // workflow.[workflow name].leave
        // workflow.[workflow name].leave.[place name]
        $events->listen(
            'workflow.straight.leave',
            'App\Listeners\BlogPostWorkflowSubscriber@onLeave'
        );

        // workflow.transition
        // workflow.[workflow name].transition
        // workflow.[workflow name].transition.[transition name]
        $events->listen(
            'workflow.straight.transition',
            'App\Listeners\BlogPostWorkflowSubscriber@onTransition'
        );

        // workflow.enter
        // workflow.[workflow name].enter
        // workflow.[workflow name].enter.[place name]
        $events->listen(
            'workflow.straight.enter',
            'App\Listeners\BlogPostWorkflowSubscriber@onEnter'
        );

        // workflow.entered
        // workflow.[workflow name].entered
        // workflow.[workflow name].entered.[place name]
        $events->listen(
            'workflow.straight.entered',
            'App\Listeners\BlogPostWorkflowSubscriber@onEntered'
        );

        // workflow.completed
        // workflow.[workflow name].completed
        // workflow.[workflow name].completed.[transition name]
        $events->listen(
            'workflow.straight.completed',
            'App\Listeners\BlogPostWorkflowSubscriber@onCompleted'
        );

        // workflow.announce
        // workflow.[workflow name].announce
        // workflow.[workflow name].announce.[transition name]
        $events->listen(
            'workflow.straight.announce',
            'App\Listeners\BlogPostWorkflowSubscriber@onAnnounce'
        );
    }
}

您也可以使用更典型的Laravel风格来订阅事件,尽管这已不再推荐,因为它可能导致根据您的事件监听方式而产生“重复”事件。

<?php

namespace App\Listeners;

use LucaTerribili\LaravelWorkflow\Events\GuardEvent;

class BlogPostWorkflowSubscriber
{
    /**
     * Handle workflow guard events.
     */
    public function onGuard(GuardEvent $event)
    {
        /** Symfony\Component\Workflow\Event\GuardEvent */
        $originalEvent = $event->getOriginalEvent();

        /** @var App\BlogPost $post */
        $post = $originalEvent->getSubject();
        $title = $post->title;

        if (empty($title)) {
            // Posts with no title should not be allowed
            $originalEvent->setBlocked(true);
        }
    }

    /**
     * Handle workflow leave event.
     */
    public function onLeave($event)
    {
        // The event can also proxy to the original event
        $subject = $event->getSubject();
        // is the same as:
        $subject = $event->getOriginalEvent()->getSubject();
    }

    /**
     * Handle workflow transition event.
     */
    public function onTransition($event) {}

    /**
     * Handle workflow enter event.
     */
    public function onEnter($event) {}

    /**
     * Handle workflow entered event.
     */
    public function onEntered($event) {}

    /**
     * Register the listeners for the subscriber.
     *
     * @param  Illuminate\Events\Dispatcher  $events
     */
    public function subscribe($events)
    {
        $events->listen(
            'LucaTerribili\LaravelWorkflow\Events\GuardEvent',
            'App\Listeners\BlogPostWorkflowSubscriber@onGuard'
        );

        $events->listen(
            'LucaTerribili\LaravelWorkflow\Events\LeaveEvent',
            'App\Listeners\BlogPostWorkflowSubscriber@onLeave'
        );

        $events->listen(
            'LucaTerribili\LaravelWorkflow\Events\TransitionEvent',
            'App\Listeners\BlogPostWorkflowSubscriber@onTransition'
        );

        $events->listen(
            'LucaTerribili\LaravelWorkflow\Events\EnterEvent',
            'App\Listeners\BlogPostWorkflowSubscriber@onEnter'
        );

        $events->listen(
            'LucaTerribili\LaravelWorkflow\Events\EnteredEvent',
            'App\Listeners\BlogPostWorkflowSubscriber@onEntered'
        );
    }

}

工作流与状态机

当使用多状态工作流时,有必要区分一个地方可以转换到多个位置的情况,或者一个主题在正好多个位置转换到同一个地方的情况。由于配置是PHP数组,您必须将后者的情况“嵌套”到数组中,以便使用位置数组构建转换,而不是通过单个位置进行循环。

示例1.正好两个位置转换到一个位置

在此示例中,草稿必须同时处于content_approvedlegal_approved状态。

<?php

return [
    'straight' => [
        'type' => 'workflow',
        'metadata' => [
            'title' => 'Blog Publishing Workflow',
        ],
        'marking_store' => [
            'property' => 'currentPlace'
        ],
        'supports' => ['App\BlogPost'],
        'places' => [
            'draft',
            'content_review',
            'content_approved',
            'legal_review',
            'legal_approved',
            'published'
        ],
        'transitions' => [
            'to_review' => [
                'from' => 'draft',
                'to' => ['content_review', 'legal_review'],
            ],
            // ... transitions to "approved" states here
            'publish' => [
                'from' => [ // note array in array
                    ['content_review', 'legal_review']
                ],
                'to' => 'published'
            ],
            // ...
        ],
    ]
];

示例2.任一两个位置转换到一个位置

在此示例中,草稿可以从content_approvedlegal_approved转换到published

<?php

return [
    'straight' => [
        'type' => 'workflow',
        'metadata' => [
            'title' => 'Blog Publishing Workflow',
        ],
        'marking_store' => [
            'property' => 'currentPlace'
        ],
        'supports' => ['App\BlogPost'],
        'places' => [
            'draft',
            'content_review',
            'content_approved',
            'legal_review',
            'legal_approved',
            'published'
        ],
        'transitions' => [
            'to_review' => [
                'from' => 'draft',
                'to' => ['content_review', 'legal_review'],
            ],
            // ... transitions to "approved" states here
            'publish' => [
                'from' => [
                    'content_review',
                    'legal_review'
                ],
                'to' => 'published'
            ],
            // ...
        ],
    ]
];

输出工作流

Symfony工作流使用GraphvizDumper创建工作流图像。您可能需要安装Graphvizdot命令。

php artisan workflow:dump workflow_name --class App\\BlogPost

您可以使用--format选项更改图像格式。默认格式是png。

php artisan workflow:dump workflow_name --format=jpg

如果您想将输出保存到根目录以外的目录,可以使用--disk--path选项来设置存储磁盘(默认为local)和路径(默认为root_path())。

php artisan workflow:dump workflow-name --class=App\\BlogPost --disk=s3 --path="workflows/diagrams/"

使用跟踪模式

如果您通过某种动态方式(可能是通过数据库)加载工作流定义,您可能希望启用注册跟踪。这将使您能够看到已加载的内容,以防止或忽略重复的工作流定义。

workflow_registry.php配置文件中将track_loaded设置为true

<?php

return [

    /**
     * When set to true, the registry will track the workflows that have been loaded.
     * This is useful when you're loading from a DB, or just loading outside of the
     * main config files.
     */
    'track_loaded' => false,

    /**
     * Only used when track_loaded = true
     *
     * When set to true, a registering a duplicate workflow will be ignored (will not load the new definition)
     * When set to false, a duplicate workflow will throw a DuplicateWorkflowException
     */
    'ignore_duplicates' => false,

];

您可以使用工作流注册表上的addFromArray方法动态加载工作流。

<?php

    /**
     * Load the workflow type definition into the registry
     */
    protected function loadWorkflow()
    {
        $registry = app()->make('workflow');
        $workflowName = 'straight';
        $workflowDefinition = [
            // Workflow definition here
            // (same format as config/symfony docs)
            // This should be the definition only,
            // not including the key for the name.
            // See note below on initial_places for an example.
        ];

        $registry->addFromArray($workflowName, $workflowDefinition);

        // or if catching duplicates

        try {
            $registry->addFromArray($workflowName, $workflowDefinition);
        } catch (DuplicateWorkflowException $e) {
            // already loaded
        }
    }

注意:动态工作流没有持久性,此包假设您以某种方式存储了这些内容(数据库等)。要使用动态工作流,您需要在使用它之前加载工作流。上面的loadWorkflow方法可以与模型的boot或类似方法相关联。

您还可以在工作流定义中指定initial_places,如果它不是“位置”列表中的第一个位置。

<?php

return [
    'type' => 'workflow', // or 'state_machine'
    'metadata' => [
        'title' => 'Blog Publishing Workflow',
    ],
    'marking_store' => [
        'property' => 'currentPlace'
    ],
    'supports' => ['App\BlogPost'],
    'places' => [
        'review',
        'rejected',
        'published',
        'draft', => [
            'metadata' => [
                'max_num_of_words' => 500,
            ]
        ]
    ],
    'initial_places' => 'draft', // or set to an array if multiple initial places
    'transitions' => [
        'to_review' => [
            'from' => 'draft',
            'to' => 'review',
            'metadata' => [
                'priority' => 0.5,
            ]
        ],
        'publish' => [
            'from' => 'review',
            'to' => 'published'
        ],
        'reject' => [
            'from' => 'review',
            'to' => 'rejected'
        ]
    ],
];