Implementing Robust Approval Workflows in Laravel #
Approval workflows are a crucial feature in many web applications, allowing for content moderation, task validation, or multi-stage processes before certain data becomes public or active. Whether you're building a blog where posts need moderator approval, an e-commerce platform where product listings require vetting, or a user-generated content site, a robust approval system is indispensable.
This tutorial will guide you through building a flexible approval workflow in Laravel, covering database design, authorization policies, controller logic, and user notifications.
1. Database Design and Migrations #
First, let's design our database to accommodate an approval status. We'll add a status column to our target model's table (e.g., posts). We'll also include approved_by_id and approved_at for auditing purposes.
Let's assume you have a posts table. If not, create a Post model and migration first.
php artisan make:model Post -m
Modify your create_posts_table migration to include the new columns:
// database/migrations/YYYY_MM_DD_create_posts_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('title');
$table->text('content');
$table->enum('status', ['pending', 'approved', 'rejected'])->default('pending');
$table->foreignId('approved_by_id')->nullable()->constrained('users')->onDelete('set null');
$table->timestamp('approved_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};
Run your migrations:
php artisan migrate
2. Model Setup #
Update your Post model to include fillable attributes, casts, and relationships.
// app/Models/Post.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
class Post extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'title',
'content',
'status',
'approved_by_id',
'approved_at',
];
protected $casts = [
'approved_at' => 'datetime',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function approver()
{
return $this->belongsTo(User::class, 'approved_by_id');
}
// Scopes for easy querying
public function scopeApproved(Builder $query): void
{
$query->where('status', 'approved');
}
public function scopePending(Builder $query): void
{
$query->where('status', 'pending');
}
public function scopeRejected(Builder $query): void
{
$query->where('status', 'rejected');
}
public function isPending(): bool
{
return $this->status === 'pending';
}
public function isApproved(): bool
{
return $this->status === 'approved';
}
public function isRejected(): bool
{
return $this->status === 'rejected';
}
public function approve(int $approverId)
{
$this->status = 'approved';
$this->approved_by_id = $approverId;
$this->approved_at = now();
$this->save();
}
public function reject(int $approverId)
{
$this->status = 'rejected';
$this->approved_by_id = $approverId;
$this->approved_at = now(); // Or set to null if rejection doesn't count as approval event
$this->save();
}
}
3. Authorization with Policies #
We need to define who can approve or reject a post. Laravel Policies are perfect for this. Let's create a PostPolicy.
php artisan make:policy PostPolicy --model=Post
Register the policy in AuthServiceProvider:
// app/Providers/AuthServiceProvider.php
protected $policies = [
Post::class => PostPolicy::class,
];
Implement the approve method in PostPolicy. We'll assume only users with a specific role (e.g., 'moderator' or 'admin') can approve.
// app/Policies/PostPolicy.php
namespace App\Policies;
use App\Models\User;
use App\Models\Post;
class PostPolicy
{
/**
* Determine whether the user can approve any posts.
*/
public function approveAny(User $user): bool
{
// Example: Only users with 'admin' or 'moderator' role can approve.
// You might have a `hasRole` method on your User model.
return $user->isAdmin() || $user->isModerator();
}
/**
* Determine whether the user can approve the given post.
*/
public function approve(User $user, Post $post): bool
{
// A user can approve a post if they have `approveAny` permission
// and the post is currently pending.
return ($user->isAdmin() || $user->isModerator()) && $post->isPending();
}
/**
* Determine whether the user can reject the given post.
*/
public function reject(User $user, Post $post): bool
{
// Same logic for rejection as for approval.
return ($user->isAdmin() || $user->isModerator()) && $post->isPending();
}
}
Note: You'll need isAdmin() and isModerator() methods on your User model, or a robust role/permission package like Spatie's laravel-permission.
4. Controller Logic #
Now, let's create a controller to handle the approval process. You might have separate controllers for users submitting posts and moderators managing them.
php artisan make:controller Moderator/PostApprovalController
// app/Http/Controllers/Moderator/PostApprovalController.php
namespace App\Http\Controllers\Moderator;
use App\Http\Controllers\Controller;
use App\Models\Post;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class PostApprovalController extends Controller
{
public function __construct()
{
$this->middleware(['auth']); // Ensure user is authenticated
}
public function pending(Request $request): View
{
$this->authorize('approveAny', Post::class); // Check if user can approve any post
$pendingPosts = Post::pending()->latest()->paginate(10);
return view('moderator.posts.pending', compact('pendingPosts'));
}
public function approve(Post $post): RedirectResponse
{
$this->authorize('approve', $post);
$post->approve(auth()->id());
// Optionally send a notification to the post author
// $post->user->notify(new PostApproved($post));
return redirect()->route('moderator.posts.pending')->with('success', 'Post approved successfully.');
}
public function reject(Post $post): RedirectResponse
{
$this->authorize('reject', $post);
$post->reject(auth()->id());
// Optionally send a notification to the post author
// $post->user->notify(new PostRejected($post));
return redirect()->route('moderator.posts.pending')->with('success', 'Post rejected.');
}
}
5. Routes #
Define routes for the approval process. It's good practice to group moderator routes.
// routes/web.php
use App\Http\Controllers\Moderator\PostApprovalController;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth'])->prefix('moderator')->name('moderator.')->group(function () {
Route::get('posts/pending', [PostApprovalController::class, 'pending'])->name('posts.pending');
Route::post('posts/{post}/approve', [PostApprovalController::class, 'approve'])->name('posts.approve');
Route::post('posts/{post}/reject', [PostApprovalController::class, 'reject'])->name('posts.reject');
});
// Example route for a user submitting a post (initial creation)
Route::post('/posts', function (Request $request) {
$request->user()->posts()->create($request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
]));
return back()->with('success', 'Post submitted for approval!');
})->middleware('auth');
// Example route for displaying approved posts
Route::get('/posts', function () {
$approvedPosts = App\Models\Post::approved()->latest()->paginate(10);
return view('posts.index', compact('approvedPosts'));
});
6. Notifications #
Notifying the author when their post is approved or rejected enhances user experience. Let's create two notification classes.
php artisan make:notification PostApproved
php artisan make:notification PostRejected
// app/Notifications/PostApproved.php
namespace App\Notifications;
use App\Models\Post;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class PostApproved extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public Post $post) { }
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Your Post Has Been Approved!')
->line('Great news! Your post titled "' . $this->post->title . '" has been approved and is now live.')
->action('View Your Post', url('/posts/' . $this->post->id))
->line('Thank you for contributing!');
}
}
// app/Notifications/PostRejected.php
namespace App\Notifications;
use App\Models\Post;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class PostRejected extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public Post $post) { }
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Update on Your Post Submission')
->line('We regret to inform you that your post titled "' . $this->post->title . '" has been rejected.')
->line('Please review our content guidelines if you wish to resubmit or have any questions.')
->action('Go to Dashboard', url('/dashboard')) // Or a page to edit the rejected post
->line('Thank you for your understanding.');
}
}
Remember to uncomment the notify calls in PostApprovalController.
7. Basic Views #
Here's a minimal example for the moderator's pending view and the author's post submission form.
resources/views/moderator/posts/pending.blade.php:
<!-- resources/views/moderator/posts/pending.blade.php -->
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Pending Posts for Approval') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900">
@if(session('success'))
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative mb-4" role="alert">
{{ session('success') }}
</div>
@endif
@forelse($pendingPosts as $post)
<div class="mb-6 p-4 border rounded">
<h3 class="text-lg font-bold">{{ $post->title }}</h3>
<p class="text-sm text-gray-600">By: {{ $post->user->name }} on {{ $post->created_at->format('M d, Y') }}</p>
<p class="mt-2">{{ Str::limit($post->content, 200) }}</p>
<div class="mt-4">
<form action="{{ route('moderator.posts.approve', $post) }}" method="POST" class="inline-block">
@csrf
<button type="submit" class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded">Approve</button>
</form>
<form action="{{ route('moderator.posts.reject', $post) }}" method="POST" class="inline-block ml-2">
@csrf
<button type="submit" class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded">Reject</button>
</form>
</div>
</div>
@empty
<p>No pending posts for approval.</p>
@endforelse
<div class="mt-4">
{{ $pendingPosts->links() }}
</div>
</div>
</div>
</div>
</div>
</x-app-layout>
Conclusion #
You've successfully implemented a basic yet powerful approval workflow in your Laravel application. This system can be extended further with features like multi-stage approvals, audit trails, user comments on pending items, or different types of content requiring different approval processes. By leveraging Laravel's built-in features like Eloquent, Policies, and Notifications, you can build complex and secure systems efficiently.