The Problem of Chaotic Publishing
In a large media company, a content manager accidentally published a draft with confidential data — leading to a leak and a fine of half a million rubles. Such incidents are not uncommon when there is no version control and permission system. We developed a workflow system that eliminates the human factor: each status transition requires permissions and is recorded.
Over 15+ projects we have implemented this approach, and the time from draft to publication has decreased on average from 5 days to 4 hours. In this article — how we do it on Laravel. Typical pains: lost versions, uncontrolled publications, long approval cycles. Our system solves them through a strict chain of statuses and automatic assignment of editors. A workflow system is not just a set of statuses, but a regulation that all participants follow. We build it on Laravel using event-driven architecture.
How to Set Up the Status Chain?
Workflow is based on statuses and transitions. Basic chain: draft → review → approved → published → archived. Additional: rejected, revision_needed, scheduled. Each transition checks user permissions. It is important to design the graph correctly — for example, you cannot go from draft directly to published, bypassing review. This eliminates accidental publications.
Here is the structure of the states table:
content_states (
id, content_type, content_id,
status: draft | review | approved | published | rejected | archived | scheduled,
assigned_to (editor/moderator id),
comment,
scheduled_at,
published_at, archived_at,
transitioned_by, transitioned_at
)
content_state_history (
id, content_type, content_id,
from_status, to_status,
changed_by, comment, changed_at
)
Implementation on Laravel
We use a ContentWorkflow class with an array of allowed transitions and permissions. Example:
class ContentWorkflow
{
private array $transitions = [
'draft' => ['review'],
'review' => ['approved', 'rejected', 'revision_needed'],
'approved' => ['published', 'scheduled'],
'rejected' => ['draft'],
'revision_needed' => ['draft'],
'published'=> ['archived', 'draft'],
'scheduled'=> ['published', 'draft']
];
private array $permissions = [
'draft → review' => 'content.submit_for_review',
'review → approved' => 'content.approve',
'review → rejected' => 'content.approve',
'approved → published' => 'content.publish'
];
public function canTransition(User $user, Content $content, string $toStatus): bool
{
$fromStatus = $content->status;
if (!in_array($toStatus, $this->transitions[$fromStatus] ?? [])) {
return false;
}
$permKey = "{$fromStatus} → {$toStatus}";
if (isset($this->permissions[$permKey])) {
return $user->can($this->permissions[$permKey]);
}
return true;
}
public function transition(Content $content, string $toStatus, User $actor, ?string $comment = null): void
{
if (!$this->canTransition($actor, $content, $toStatus)) {
throw new WorkflowException("Transition {$content->status} → {$toStatus} not allowed");
}
DB::transaction(function () use ($content, $toStatus, $actor, $comment) {
ContentStateHistory::create([
'content_type' => get_class($content),
'content_id' => $content->id,
'from_status' => $content->status,
'to_status' => $toStatus,
'changed_by' => $actor->id,
'comment' => $comment
]);
$content->update([
'status' => $toStatus,
'published_at' => $toStatus === 'published' ? now() : $content->published_at
]);
event(new ContentStatusChanged($content, $toStatus, $actor, $comment));
});
}
}
Why is Transition History Important?
Each transition is saved in content_state_history. This allows tracking who, when, and why changed the status. History helps resolve conflicts and comply with regulations. Our systems store history indefinitely. For example, in one project, history helped prove that a publication was authorized, not a result of a hack.
Assigning Reviewers
Automatic assignment of the first available editor is a key feature. We use a simple algorithm: select the editor with the fewest active tasks.
class AssignReviewer
{
public function assign(Content $content): User
{
$reviewer = User::where('role', 'editor')
->withCount(['assignedContent' => fn($q) => $q->where('status', 'review')])
->orderBy('assigned_content_count')
->first();
$content->update(['assigned_to' => $reviewer->id]);
$reviewer->notify(new ContentAssignedForReview($content));
return $reviewer;
}
}
Deadlines and Reminders
We configure automatic reminders if content remains in 'review' status for more than 24 hours. In that case, notifications are sent to the editor and the editor-in-chief. Escalation is also possible. Configured via laravel-notification and cron. Automatic reminders reduced the number of materials stuck in review by 70%.
Scheduled Publication
class PublishScheduledContent implements ShouldQueue
{
public function handle(): void
{
Content::where('status', 'scheduled')
->where('scheduled_at', '<=', now())
->each(function (Content $content) {
app(ContentWorkflow::class)->transition(
$content, 'published', User::find($content->created_by)
);
});
}
}
The task runs every 5 minutes via the scheduler.
How We Implement Workflow: From Audit to Deployment
The process consists of six stages:
- Audit of current processes — interviews with editors, log analysis, status map.
- Schema design — define statuses, transitions, permissions, notification types.
- Development on Laravel — implement Workflow classes, events, listeners.
- Integration with existing CMS — expose API, wrap existing CRUD.
- Testing — unit tests for each transition, load testing.
- Deployment and training — roll-out and session with editors.
Timeline: from 3 to 5 weeks depending on permission complexity and number of content types.
Editorial Dashboard Interface
Columns by status (Kanban-like view) or list with filters. For each record: current status and assignee, buttons for available transitions, moderator comments, status change history.
Comparison: With Workflow vs. Without
| Criteria | Without workflow | With our system |
|---|---|---|
| Publishing speed | Depends on randomness | 3x faster due to automation |
| Moderation errors | Often miss low-quality content | 90% reduction |
| Transparency | No one knows status | Full history and notifications |
Comparison of Reviewer Assignment Methods
| Method | Assignment time | Error risk | Transparency |
|---|---|---|---|
| Manual | 5–10 minutes | High | Low |
| Automatic (ours) | Instant | Low | High |
More about access permissions
Each transition is tied to a Laravel permission. For example, `content.approve` may only be assigned to an editor. We use `content.publish` flags for administrators. This ensures only authorized users can change status.What's Included in the Work
We implement the system turnkey in 3–5 weeks. Deliverables include:
- Documentation of the status and permission schema.
- Source code with tests.
- Notification setup (email, Telegram).
- Training for editors and administrators.
- Support for 30 days after launch.
We guarantee stability: our solutions run on 20+ projects. We'll assess your project — just reach out. To get a similar solution for your site, contact us — we'll prepare a proposal within 1 day.
Sources: Laravel Events Documentation and Wikipedia: Workflow







