Skip to content

Article Publishing Example

Jean-Marc Strauven edited this page Aug 6, 2025 · 2 revisions

πŸ“° 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.

🎯 Overview

The Article Publishing Workflow manages the complete editorial lifecycle:

  1. Draft Creation - Author creates initial draft
  2. Editorial Review - Editor reviews content for quality and guidelines
  3. Publication - Approved articles are published
  4. Revision Process - Rejected articles can be revised and resubmitted

πŸ“‹ YAML Configuration

Simple Article Workflow

# 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 Article Workflow

# 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

πŸ—οΈ Model Setup

Article Model

<?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);
    }
}

πŸ›‘οΈ Guards Implementation

IsEditor Guard

<?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;
    }
}

IsAuthorOrEditor Guard

<?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;
    }
}

HasRequiredContent Guard

<?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;
    }
}

βš™οΈ Actions Implementation

NotifyEditor Action

<?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();
    }
}

NotifyAuthor Action

<?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()
        ]);
    }
}

PublishArticle Action

<?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);
    }
}

πŸ§ͺ Comprehensive Tests

Feature Tests

<?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'));
    }
}

πŸ“Š Usage Examples

Controller Integration

<?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.');
    }
}

🎯 Key Features Demonstrated

This Article Publishing example showcases:

  1. Role-Based Permissions - Different actions available to authors vs editors
  2. Content Validation - Ensuring articles meet quality standards before publication
  3. Editorial Workflow - Multi-step review and approval process
  4. Notification System - Automated emails for status changes
  5. SEO Integration - Automatic metadata generation and slug creation
  6. Audit Trail - Complete history of editorial decisions
  7. Revision Process - Ability to revise and resubmit rejected content

πŸ”— Related Examples


Perfect for: Content management systems, blog platforms, editorial workflows, publishing platforms, and any application requiring content approval processes.

Clone this wiki locally