This website is currently under active development (Beta) 🚀. Some features are still work in progress.
Laravel Tutorial

Implementing Robust Approval Workflows in Laravel

Admin User
Admin User
Sep 05, 2026
8 min read

Key Takeaways

  • # Implementing Robust Approval Workflows in Laravel
  • Approval workflows are a crucial feature in many web applications, allowing for content moderation, task va...

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.

FAQs

What is an approval workflow in Laravel?
An approval workflow in Laravel is a system designed to manage and control the lifecycle of content or data, requiring specific authorization steps (e.g., by an admin or moderator) before it becomes public, active, or finalized. It typically involves status changes, permissions, and notifications.
How can I implement multi-stage approvals?
For multi-stage approvals, you can extend the `status` column to include more states (e.g., `pending_review_1`, `pending_review_2`, `final_approved`). Each stage would have its own policy method and potentially different approvers. You'd track the current stage and move to the next upon approval, or revert to an earlier stage upon rejection.
What are the benefits of using Laravel Policies for approval systems?
Laravel Policies centralize authorization logic, making your code cleaner and more maintainable. They provide a clear, object-oriented way to define who can interact with specific models, ensuring that only authorized users can perform approval or rejection actions, which significantly enhances security and code organization.

Want more content like this?

Explore more tutorials in the Laravel section.

Explore Laravel

You might also like