Workflow de Artigos (CMS)
Exemplo de máquina de estados para artigos editoriais, demonstrando ramos de rejeição com feedback, autorização via Gate (sem
authorizeFor) e integração com versionamento (arqel-dev/versioning).
Visão geral
CMS editorial é um caso de uso onde o fluxo de revisão é o coração do produto. Diferente de pedidos (onde o estado avança quase sempre por sistemas/eventos externos), aqui as transições são quase todas humanas: autor envia para revisão, editor aprova ou devolve com feedback, alguém arquiva quando o conteúdo perde relevância. A natureza colaborativa exige duas coisas que este exemplo destaca: (1) feedback estruturado na rejeição — o reviewer escreve um comentário que volta junto com o artigo para o autor; e (2) histórico imutável que o arqel-dev/workflow grava por padrão, complementado por versões do conteúdo geradas pelo arqel-dev/versioning em momentos-chave.
O workflow é Draft → InReview → Published → Archived, com duas saídas alternativas: InReview → Draft (rejeição com motivo) e Published → Archived (sunset, qualquer pessoa autenticada). A decisão importante de design aqui é não usar authorizeFor em nenhuma transition — em vez disso, todas autorizam via Gate registrada em AuthServiceProvider. Isso facilita testes (Gates são fáceis de fakeAr com Gate::shouldReceive), mantém a lógica de autorização agrupada num único lugar e permite que o time de produto altere regras (por exemplo, "qualquer editor pode rejeitar, mas só o editor-chefe pode publicar") sem mexer em transition classes.
A integração com versionamento é o detalhe que diferencia este workflow: cada vez que o artigo entra em InReview ou Published, um snapshot é criado em versions (via trait Versionable). Isso permite voltar a uma revisão anterior se uma publicação se mostrar problemática, e ver o "diff" entre versões na UI do admin.
Diagrama de estados
stateDiagram-v2
[*] --> Draft
Draft --> InReview : DraftToInReview<br/>(autor)
InReview --> Published : InReviewToPublished<br/>(editor)
InReview --> Draft : InReviewToDraft<br/>(reject + feedback)
Published --> Archived : PublishedToArchived<br/>(qualquer user)
Archived --> [*]Note que não permitimos Archived → Draft ou Published → Draft. Se um artigo arquivado precisa voltar à edição, o fluxo é "duplicar como rascunho" (uma Action no Resource, não uma transition de workflow). Isso preserva o histórico de publicação intacto.
Model Eloquent
<?php
declare(strict_types=1);
namespace App\Models;
use App\Models\ArticleState;
use App\Workflows\Articles\Transitions;
use Arqel\Versioning\Concerns\Versionable;
use Arqel\Workflow\Concerns\HasWorkflow;
use Arqel\Workflow\WorkflowDefinition;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class Article extends Model
{
use HasWorkflow;
use Versionable;
protected $fillable = [
'title',
'slug',
'body',
'author_id',
'editor_id',
'article_state',
'review_feedback',
'published_at',
];
protected $casts = [
'article_state' => ArticleState::class,
'published_at' => 'datetime',
];
/** @var list<string> Atributos versionados pelo arqel-dev/versioning. */
protected array $versionedAttributes = ['title', 'slug', 'body'];
public function arqelWorkflow(): WorkflowDefinition
{
return WorkflowDefinition::make('article_state')
->states([
ArticleState\Draft::class => ['label' => 'Rascunho', 'color' => 'secondary', 'icon' => 'edit-3'],
ArticleState\InReview::class => ['label' => 'Em revisão', 'color' => 'warning', 'icon' => 'eye'],
ArticleState\Published::class => ['label' => 'Publicado', 'color' => 'success', 'icon' => 'globe'],
ArticleState\Archived::class => ['label' => 'Arquivado', 'color' => 'muted', 'icon' => 'archive'],
])
->transitions([
Transitions\DraftToInReview::class,
Transitions\InReviewToPublished::class,
Transitions\InReviewToDraft::class,
Transitions\PublishedToArchived::class,
]);
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'author_id');
}
public function editor(): BelongsTo
{
return $this->belongsTo(User::class, 'editor_id');
}
}Versionable cria entradas em versions automaticamente nos hooks created/updating — ver arqel-dev/versioning SKILL.md. Aqui só listamos os atributos relevantes em $versionedAttributes; mudanças em article_state ou editor_id não geram nova versão (só alterações no conteúdo).
Resource
<?php
declare(strict_types=1);
namespace App\Arqel\Resources;
use App\Models\Article;
use App\Models\ArticleState;
use Arqel\Core\Resource;
use Arqel\Fields\RichText;
use Arqel\Fields\Text;
use Arqel\Fields\Textarea;
use Arqel\Versioning\Fields\VersionHistory;
use Arqel\Workflow\Fields\StateTransitionField;
final class ArticleResource extends Resource
{
protected static string $model = Article::class;
public function fields(): array
{
return [
Text::make('title')->required()->maxLength(180),
Text::make('slug')->required()->unique(ignoreRecord: true),
StateTransitionField::make('article_state')
->label('Status editorial')
->showDescription()
->showHistory(),
// Feedback de rejeição: visível apenas quando estado é Draft e há feedback prévio
Textarea::make('review_feedback')
->label('Feedback do editor')
->readonly()
->visibleWhen(fn (Article $r) =>
$r->article_state instanceof ArticleState\Draft && filled($r->review_feedback)
),
RichText::make('body')->required(),
VersionHistory::make()
->label('Histórico de versões')
->visibleOn(['view']),
];
}
}O campo VersionHistory (do arqel-dev/versioning) renderiza um diff visual entre revisões — permite ao editor comparar a versão atual com a publicada e decidir se aprova mudanças.
Transition class — rejeição com feedback
<?php
declare(strict_types=1);
namespace App\Workflows\Articles\Transitions;
use App\Models\Article;
use App\Models\ArticleState;
final class InReviewToDraft
{
public function __construct(
private readonly Article $article,
private readonly string $feedback,
) {}
/** @return list<class-string> */
public static function from(): array
{
return [ArticleState\InReview::class];
}
public static function to(): string
{
return ArticleState\Draft::class;
}
public function handle(): Article
{
$this->article->article_state = ArticleState\Draft::class;
$this->article->review_feedback = $this->feedback;
$this->article->editor_id = auth()->id();
$this->article->save();
return $this->article;
}
}A autorização não está aqui — está na Gate. Isso é proposital: se amanhã o time decidir "qualquer editor pode rejeitar, mas só o editor-chefe pode publicar", a mudança é um único arquivo (AuthServiceProvider).
Authorization via Gate
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Models\Article;
use App\Models\User;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
final class AuthServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Autor (criador do artigo) pode mover de Draft para InReview.
Gate::define('transition-draft-to-in-review', function (User $user, Article $article): bool {
return $user->id === $article->author_id || $user->hasRole('editor');
});
// Apenas editores podem aprovar publicação.
Gate::define('transition-in-review-to-published', function (User $user, Article $article): bool {
return $user->hasRole('editor');
});
// Qualquer editor pode rejeitar (devolver com feedback).
Gate::define('transition-in-review-to-draft', function (User $user, Article $article): bool {
return $user->hasRole('editor');
});
// Arquivar é "limpeza" — qualquer user autenticado pode (com auditoria via histórico).
Gate::define('transition-published-to-archived', function (User $user, Article $article): bool {
return $user !== null;
});
}
}Os nomes seguem o pattern transition-{from-slug}-to-{to-slug} que o TransitionAuthorizer busca automaticamente — o slug é a última parte do FQCN sem o sufixo State, kebab-case.
Filter por estado na Table
use App\Models\Article;
use Arqel\Workflow\Filters\StateFilter;
public function table(): Table
{
return Table::make()
->columns([
TextColumn::make('title'),
TextColumn::make('author.name'),
BadgeColumn::make('article_state')->colorsFromWorkflow(Article::class),
DateTimeColumn::make('published_at')->placeholder('—'),
])
->filters([
StateFilter::make('article_state', Article::class)
->label('Status editorial'),
])
->defaultFilters([
'article_state' => [
\App\Models\ArticleState\Draft::class,
\App\Models\ArticleState\InReview::class,
],
]);
}defaultFilters faz o admin abrir já filtrado em "trabalho em andamento" (Draft + InReview) — bom UX para editores.
Listener — snapshot + notificação
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Mail\ArticleReviewRequested;
use App\Models\Article;
use App\Models\ArticleState;
use App\Models\User;
use Arqel\Workflow\Events\StateTransitioned;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;
final class NotifyEditorialBoard implements ShouldQueue
{
public function handle(StateTransitioned $event): void
{
if (! $event->record instanceof Article) {
return;
}
match ($event->to) {
ArticleState\InReview::class => $this->onSubmittedForReview($event->record),
ArticleState\Published::class => $this->onPublished($event->record),
ArticleState\Draft::class => $this->onRejected($event->record, $event->context),
default => null,
};
}
private function onSubmittedForReview(Article $article): void
{
$editors = User::role('editor')->get();
Mail::to($editors)->send(new ArticleReviewRequested($article));
}
private function onPublished(Article $article): void
{
// Dispara webhook para CDN purge, indexação no Algolia, etc.
\App\Jobs\PublishArticleSideEffects::dispatch($article);
}
/** @param array<string,mixed> $context */
private function onRejected(Article $article, array $context): void
{
if ($article->author === null) {
return;
}
Mail::to($article->author)->send(
new \App\Mail\ArticleRejected(
article: $article,
feedback: $context['feedback'] ?? $article->review_feedback ?? '',
),
);
}
}Note como o listener é um único que faz match() no estado destino — alternativa a três listeners separados. Para listeners pequenos é mais legível; para listeners grandes ou com dependências distintas, separar em múltiplas classes (como em order-states.md) é melhor.
Integração com arqel-dev/versioning
Quando o artigo entra em Published, o Versionable trait já cuida de criar uma versão com tag canônica:
// Listener adicional, opcional — força tag de "publicação" na versão criada.
final class TagPublishedVersion
{
public function handle(StateTransitioned $event): void
{
if (! $event->record instanceof Article || $event->to !== ArticleState\Published::class) {
return;
}
$event->record->latestVersion()?->update([
'tag' => 'published',
'published_at' => now(),
]);
}
}A UI de VersionHistory filtra por tag 'published' para mostrar uma timeline limpa de "versões publicadas" no admin, ignorando rascunhos intermediários.
Resumo das decisões
- Sem
authorizeFor— só Gate: regras editoriais mudam frequentemente; concentrá-las emAuthServiceProvidersimplifica revisão. - Sem
Archived → Draft: arquivamento é definitivo. Voltar editoria = duplicar. - Feedback no
contextda transition: usuário escreve no controller, vai parametadatado histórico, e também copia parareview_feedbackno model para fácil exibição. - Versionamento ortogonal:
arqel-dev/versioningcuida de snapshots; o workflow cuida do estado. Combinam mas não dependem.