-
-
Notifications
You must be signed in to change notification settings - Fork 0
Article Publishing Example
This example demonstrates a content management and publishing workflow using Laravel Statecraft. It covers the complete editorial process from draft creation to publication, including multi-step review processes and role-based permissions.
The Article Publishing Workflow manages the complete editorial lifecycle:
- Draft Creation - Author creates initial draft
- Editorial Review - Editor reviews content for quality and guidelines
- Publication - Approved articles are published
- Revision Process - Rejected articles can be revised and resubmitted
# resources/state-machines/ArticleStateMachine.yaml
state_machine:
name: simple-article-workflow
model: App\Models\Article
field: status
states: [draft, review, published, rejected]
initial: draft
transitions:
- from: draft
to: review
guard: Examples\ArticlePublishing\Guards\IsAuthorOrEditor
action: Examples\ArticlePublishing\Actions\NotifyEditor
- from: review
to: published
guard: Examples\ArticlePublishing\Guards\IsEditor
action: Examples\ArticlePublishing\Actions\NotifyAuthor
- from: review
to: rejected
guard: Examples\ArticlePublishing\Guards\IsEditor
action: Examples\ArticlePublishing\Actions\NotifyAuthor
- from: rejected
to: review
guard: Examples\ArticlePublishing\Guards\IsAuthorOrEditor
action: Examples\ArticlePublishing\Actions\NotifyEditor# Advanced workflow with more states and complex logic
name: ArticleStateMachine
model: App\Models\Article
initial_state: draft
field: status
description: "Advanced content management and publishing workflow"
states:
- name: draft
description: Article is being written
metadata:
color: gray
editable: true
- name: review
description: Article is under editorial review
metadata:
color: yellow
requires_editor: true
- name: copy_edit
description: Article is being copy-edited
metadata:
color: blue
department: editorial
- name: published
description: Article is live and public
metadata:
color: green
final: true
- name: rejected
description: Article was rejected
metadata:
color: red
- name: archived
description: Article was archived
metadata:
color: gray
final: true
transitions:
- name: submit_for_review
from: draft
to: review
guard:
and:
- IsAuthorOrEditor
- HasRequiredContent
- HasValidCategory
action:
- NotifyEditor
- UpdateSubmissionDate
metadata:
description: "Submit article for editorial review"
- name: approve_for_copy_edit
from: review
to: copy_edit
guard:
and:
- IsEditor
- MeetsQualityStandards
action:
- NotifyCopyEditor
- CreateEditorialNotes
- name: publish
from: [copy_edit, review]
to: published
guard:
and:
- IsEditor
- HasValidSlug
- IsScheduledOrImmediate
action:
- PublishArticle
- NotifyAuthor
- UpdateSEOMetadata
- TriggerSocialSharing
metadata:
requires_seo_check: true
- name: reject
from: [review, copy_edit]
to: rejected
guard: IsEditor
action:
- NotifyAuthor
- CreateRejectionFeedback
metadata:
requires_reason: true
- name: revise
from: rejected
to: draft
guard: IsAuthorOrEditor
action:
- NotifyEditor
- CreateRevisionLog
- name: archive
from: [published, rejected]
to: archived
guard:
or:
- IsEditor
- IsOlderThan: "1 year"
action: ArchiveArticle<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Grazulex\LaravelStatecraft\Traits\HasStateMachine;
class Article extends Model
{
use HasFactory, HasStateMachine;
protected $stateMachine = 'ArticleStateMachine';
protected $fillable = [
'title',
'content',
'excerpt',
'slug',
'author_id',
'editor_id',
'category_id',
'status',
'published_at',
'featured_image',
'meta_description',
'meta_keywords',
];
protected $casts = [
'published_at' => 'datetime',
'meta_keywords' => 'array',
];
// Relationships
public function author()
{
return $this->belongsTo(User::class, 'author_id');
}
public function editor()
{
return $this->belongsTo(User::class, 'editor_id');
}
public function category()
{
return $this->belongsTo(Category::class);
}
public function tags()
{
return $this->belongsToMany(Tag::class);
}
// Helper methods for guards
public function hasRequiredContent(): bool
{
return !empty($this->title) &&
!empty($this->content) &&
strlen($this->content) >= 100;
}
public function hasValidSlug(): bool
{
return !empty($this->slug) &&
!static::where('slug', $this->slug)
->where('id', '!=', $this->id)
->exists();
}
public function meetsQualityStandards(): bool
{
return strlen($this->content) >= 500 &&
!empty($this->excerpt);
}
}<?php
namespace App\StateMachine\Guards;
use Grazulex\LaravelStatecraft\Contracts\Guard;
use Illuminate\Database\Eloquent\Model;
class IsEditor implements Guard
{
/**
* Check if the current user has editor permissions.
*/
public function check(Model $model, string $from, string $to): bool
{
$user = auth()->user();
if (!$user) {
return false;
}
// Check if user has editor role
if (method_exists($user, 'hasRole')) {
return $user->hasRole('editor');
}
// Alternative: check for specific permission
if (method_exists($user, 'can')) {
return $user->can('edit_articles');
}
// Fallback: check user property
return $user->is_editor ?? false;
}
}<?php
namespace App\StateMachine\Guards;
use Grazulex\LaravelStatecraft\Contracts\Guard;
use Illuminate\Database\Eloquent\Model;
class IsAuthorOrEditor implements Guard
{
public function check(Model $model, string $from, string $to): bool
{
$user = auth()->user();
if (!$user) {
return false;
}
// Check if user is the author
if ($model->author_id === $user->id) {
return true;
}
// Check if user is an editor
if (method_exists($user, 'hasRole') && $user->hasRole('editor')) {
return true;
}
if (method_exists($user, 'can') && $user->can('edit_articles')) {
return true;
}
return $user->is_editor ?? false;
}
}<?php
namespace App\StateMachine\Guards;
use Grazulex\LaravelStatecraft\Contracts\Guard;
use Illuminate\Database\Eloquent\Model;
class HasRequiredContent implements Guard
{
public function check(Model $model, string $from, string $to): bool
{
// Check basic content requirements
if (empty($model->title) || empty($model->content)) {
return false;
}
// Minimum content length
if (strlen($model->content) < 100) {
return false;
}
// Check for valid category
if (!$model->category_id || !$model->category) {
return false;
}
return true;
}
}<?php
namespace App\StateMachine\Actions;
use Grazulex\LaravelStatecraft\Contracts\Action;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Mail;
use App\Mail\ArticleSubmittedForReview;
class NotifyEditor implements Action
{
public function execute(Model $model, string $from, string $to): void
{
// Find available editors
$editors = User::where('is_editor', true)->get();
if ($editors->isEmpty()) {
return;
}
// Assign to least busy editor or round-robin
$editor = $this->findBestEditor($editors);
if ($editor) {
$model->update(['editor_id' => $editor->id]);
// Send notification email
Mail::to($editor->email)->send(
new ArticleSubmittedForReview($model, $editor)
);
// Log the notification
$model->editorial_logs()->create([
'action' => 'editor_notified',
'user_id' => $editor->id,
'notes' => "Article assigned to {$editor->name} for review",
'created_at' => now(),
]);
}
}
private function findBestEditor($editors)
{
// Simple round-robin or least busy logic
return $editors->sortBy(function ($editor) {
return $editor->articles()->where('status', 'review')->count();
})->first();
}
}<?php
namespace App\StateMachine\Actions;
use Grazulex\LaravelStatecraft\Contracts\Action;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Mail;
use App\Mail\ArticleStatusChanged;
class NotifyAuthor implements Action
{
public function execute(Model $model, string $from, string $to): void
{
if (!$model->author) {
return;
}
// Send appropriate notification based on transition
$mailClass = match ($to) {
'published' => \App\Mail\ArticlePublished::class,
'rejected' => \App\Mail\ArticleRejected::class,
'copy_edit' => \App\Mail\ArticleInCopyEdit::class,
default => ArticleStatusChanged::class,
};
Mail::to($model->author->email)->send(
new $mailClass($model)
);
// Update last notification time
$model->update([
'last_author_notification' => now()
]);
}
}<?php
namespace App\StateMachine\Actions;
use Grazulex\LaravelStatecraft\Contracts\Action;
use Illuminate\Database\Eloquent\Model;
use App\Services\SEOService;
use App\Services\SocialMediaService;
class PublishArticle implements Action
{
public function __construct(
private SEOService $seoService,
private SocialMediaService $socialService
) {}
public function execute(Model $model, string $from, string $to): void
{
// Set publication timestamp
$model->update([
'published_at' => now(),
'published_by' => auth()->id(),
]);
// Generate and update SEO metadata
$this->seoService->generateMetadata($model);
// Create URL slug if not exists
if (empty($model->slug)) {
$model->update([
'slug' => $this->generateSlug($model->title)
]);
}
// Clear any caches
cache()->tags(['articles', 'homepage'])->flush();
// Schedule social media posts
$this->socialService->schedulePost($model);
// Update author statistics
$model->author->increment('published_articles_count');
// Create publication log
$model->editorial_logs()->create([
'action' => 'published',
'user_id' => auth()->id(),
'notes' => 'Article published successfully',
'metadata' => [
'published_at' => now(),
'slug' => $model->slug,
],
]);
}
private function generateSlug(string $title): string
{
return \Str::slug($title);
}
}<?php
namespace Tests\Feature;
use App\Models\Article;
use App\Models\User;
use App\Models\Category;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class ArticleWorkflowTest extends TestCase
{
use RefreshDatabase;
public function test_complete_article_publishing_workflow()
{
Mail::fake();
$author = User::factory()->create();
$editor = User::factory()->editor()->create();
$category = Category::factory()->create();
$article = Article::factory()->create([
'author_id' => $author->id,
'category_id' => $category->id,
'title' => 'Test Article',
'content' => str_repeat('This is test content. ', 50), // Enough content
'status' => 'draft',
]);
// Test submission for review
$this->actingAs($author);
$this->assertTrue($article->canTransitionTo('submit_for_review'));
$article->transitionTo('submit_for_review');
$this->assertEquals('review', $article->getCurrentState());
// Verify editor was notified
Mail::assertSent(\App\Mail\ArticleSubmittedForReview::class);
// Test editorial approval
$this->actingAs($editor);
$article->transitionTo('publish');
$this->assertEquals('published', $article->getCurrentState());
$this->assertNotNull($article->published_at);
// Verify author was notified
Mail::assertSent(\App\Mail\ArticlePublished::class);
}
public function test_article_rejection_and_revision_flow()
{
$author = User::factory()->create();
$editor = User::factory()->editor()->create();
$article = Article::factory()->inReview()->create([
'author_id' => $author->id,
'editor_id' => $editor->id,
]);
// Editor rejects the article
$this->actingAs($editor);
$article->transitionTo('reject');
$this->assertEquals('rejected', $article->getCurrentState());
// Author revises and resubmits
$this->actingAs($author);
$article->transitionTo('revise');
$this->assertEquals('draft', $article->getCurrentState());
// Can resubmit after revision
$this->assertTrue($article->canTransitionTo('submit_for_review'));
}
public function test_only_editors_can_publish_articles()
{
$author = User::factory()->create(); // Regular user, not editor
$article = Article::factory()->inReview()->create([
'author_id' => $author->id,
]);
$this->actingAs($author);
$this->assertFalse($article->canTransitionTo('publish'));
}
public function test_insufficient_content_prevents_submission()
{
$author = User::factory()->create();
$article = Article::factory()->create([
'author_id' => $author->id,
'content' => 'Too short', // Insufficient content
]);
$this->actingAs($author);
$this->assertFalse($article->canTransitionTo('submit_for_review'));
}
}<?php
namespace App\Http\Controllers;
use App\Models\Article;
use Illuminate\Http\Request;
use Grazulex\LaravelStatecraft\Exceptions\TransitionNotAllowedException;
class ArticleController extends Controller
{
public function submit(Article $article)
{
$this->authorize('update', $article);
try {
$article->transitionTo('submit_for_review');
return redirect()->route('articles.show', $article)
->with('success', 'Article submitted for review successfully.');
} catch (TransitionNotAllowedException $e) {
return back()->withErrors(['error' => 'Cannot submit article: ' . $e->getMessage()]);
}
}
public function publish(Article $article)
{
$this->authorize('publish', $article);
if (!$article->canTransitionTo('publish')) {
return back()->withErrors(['error' => 'Article cannot be published at this time.']);
}
$article->transitionTo('publish');
return redirect()->route('articles.show', $article)
->with('success', 'Article published successfully!');
}
public function reject(Request $request, Article $article)
{
$this->authorize('edit', $article);
$request->validate([
'rejection_reason' => 'required|string|max:1000',
]);
$article->transitionTo('reject', [
'rejection_reason' => $request->rejection_reason,
]);
return back()->with('success', 'Article rejected with feedback.');
}
}This Article Publishing example showcases:
- Role-Based Permissions - Different actions available to authors vs editors
- Content Validation - Ensuring articles meet quality standards before publication
- Editorial Workflow - Multi-step review and approval process
- Notification System - Automated emails for status changes
- SEO Integration - Automatic metadata generation and slug creation
- Audit Trail - Complete history of editorial decisions
- Revision Process - Ability to revise and resubmit rejected content
- Order Workflow Example - E-commerce order processing
- User Subscription Example - Subscription lifecycle management
- Event Usage Example - Advanced event handling
Perfect for: Content management systems, blog platforms, editorial workflows, publishing platforms, and any application requiring content approval processes.
π― Laravel Statecraft - Advanced State Machine Implementation for Laravel
Navigate: π Home | π¦ Installation | π Basic Guide | π YAML Config | π‘ Examples
Resources: π‘οΈ Guards & Actions | π― Events | π State History | π§ͺ Testing | π¨ Commands
π Community Resources:
Made with β€οΈ for the Laravel community β’ Contribute β’ License