DEV Community

Cover image for Build a Rule-Based Product Tag Manager in Laravel 12.x — Step-by-Step Guide
Chirag Patel
Chirag Patel

Posted on

Build a Rule-Based Product Tag Manager in Laravel 12.x — Step-by-Step Guide

In this article, we'll build a Product Tag Manager module in Laravel 12.x from scratch — step by step.

The idea is simple:

  • Admin adds products (name, price, SKU, vendor, type, image, etc.)
  • Admin creates rules with conditions like vendor == Supp.X AND price > 10
  • When the admin clicks Apply Rule, the system scans all products, checks all conditions, and if all match → it auto-applies tags to that product

No manual tagging. Pure rule-based automation.

Github Repo: github.com/chiragpatel009/product-tag-manager


🧱 Tech Stack

Category Technology / Requirement
Backend Framework Laravel 12.x
Programming Language PHP 8.2+
Database MySQL 8.0+
Frontend Blade + TailwindCSS (CDN)
Rich Text Editor Quill.js (via CDN)
Architecture Pattern Laravel Resource Controllers
File Storage Laravel Storage (image upload)

Step 1 — Create the Project

composer create-project laravel/laravel product-tag-manager
cd product-tag-manager
Enter fullscreen mode Exit fullscreen mode

Configure your .env:

APP_NAME="Product Tag Manager"
APP_URL=http://localhost:8000

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=product_tag_manager
DB_USERNAME=root
DB_PASSWORD=
Enter fullscreen mode Exit fullscreen mode

Create the database:

CREATE DATABASE product_tag_manager CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
Enter fullscreen mode Exit fullscreen mode

Step 2 — Migrations

Generate the migration files:

php artisan make:migration create_products_table
php artisan make:migration create_rules_table
php artisan make:migration create_rule_conditions_table
Enter fullscreen mode Exit fullscreen mode

File: create_products_table

public function up(): void
    {
        Schema::create('products', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->longText('description')->nullable();
            $table->decimal('price', 10, 2);
            $table->string('sku')->unique();
            $table->unsignedInteger('qty')->default(0);
            $table->string('type')->nullable();
            $table->string('vendor')->nullable();
            $table->string('image')->nullable();
            $table->string('tags')->nullable(); // comma-separated, non-editable, set by rules
            $table->timestamps();
        });
    }
Enter fullscreen mode Exit fullscreen mode

File: create_rules_table

public function up(): void
    {
        Schema::create('rules', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('apply_tags'); // comma-separated tag values
            $table->timestamps();
        });
    }
Enter fullscreen mode Exit fullscreen mode

File: create_rule_conditions_table

public function up(): void
    {
        Schema::create('rule_conditions', function (Blueprint $table) {
            $table->id();
            $table->foreignId('rule_id')->constrained()->cascadeOnDelete();
            $table->enum('product_selector', ['type', 'sku', 'vendor', 'price', 'qty']);
            $table->enum('operator', ['==', '>', '<']);
            $table->string('value');
            $table->timestamps();
        });
    }
Enter fullscreen mode Exit fullscreen mode

Run migrations:

php artisan migrate
Enter fullscreen mode Exit fullscreen mode

Step 3 — Models

Generate the model files:

php artisan make:model Product
php artisan make:model Rule
php artisan make:model RuleCondition
Enter fullscreen mode Exit fullscreen mode

File: app/Models/Product.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    protected $fillable = [
        'name', 'description', 'price', 'sku',
        'qty', 'type', 'vendor', 'image', 'tags',
    ];
}
Enter fullscreen mode Exit fullscreen mode

File: app/Models/Rule.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Rule extends Model
{
    protected $fillable = ['name', 'apply_tags'];

    public function conditions()
    {
        return $this->hasMany(RuleCondition::class);
    }
}
Enter fullscreen mode Exit fullscreen mode

File: app/Models/RuleCondition.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class RuleCondition extends Model
{
    protected $fillable = ['rule_id', 'product_selector', 'operator', 'value'];

    public function rule()
    {
        return $this->belongsTo(Rule::class);
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 4 — Resource Controllers

Generate the controller files:

php artisan make:controller ProductController --resource
php artisan make:controller RuleController --resource
Enter fullscreen mode Exit fullscreen mode

File: app/Http/Controllers/ProductController.php

<?php

namespace App\Http\Controllers;

use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class ProductController extends Controller
{
    /**
     * Display a listing of the resource.
     */
    public function index()
    {
        $products = Product::latest()->paginate(10);
        return view('products.index', compact('products'));
    }

    /**
     * Show the form for creating a new resource.
     */
    public function create()
    {
        return view('products.create');
    }

    /**
     * Store a newly created resource in storage.
     */
    public function store(Request $request)
    {
        $validated = $request->validate([
            'name'        => 'required|string|max:255',
            'description' => 'nullable|string',
            'price'       => 'required|numeric|min:0',
            'sku'         => 'required|string|unique:products,sku',
            'qty'         => 'required|integer|min:0',
            'type'        => 'nullable|string|max:255',
            'vendor'      => 'nullable|string|max:255',
            'image'       => 'nullable|image|max:2048',
        ]);

        if ($request->hasFile('image')) {
            $validated['image'] = $request->file('image')->store('products', 'public');
        }

        Product::create($validated);

        return redirect()->route('products.index')
            ->with('success', 'Product added successfully.');
    }

    /**
     * Display the specified resource.
     */
    public function show(Product $product)
    {
        return redirect()->route('products.index');
    }

    /**
     * Show the form for editing the specified resource.
     */
    public function edit(Product $product)
    {
        return view('products.edit', compact('product'));
    }

    /**
     * Update the specified resource in storage.
     */
    public function update(Request $request, Product $product)
    {
        $validated = $request->validate([
            'name'        => 'required|string|max:255',
            'description' => 'nullable|string',
            'price'       => 'required|numeric|min:0',
            'sku'         => 'required|string|unique:products,sku,' . $product->id,
            'qty'         => 'required|integer|min:0',
            'type'        => 'nullable|string|max:255',
            'vendor'      => 'nullable|string|max:255',
            'image'       => 'nullable|image|max:2048',
        ]);

        if ($request->hasFile('image')) {
            if ($product->image) {
                Storage::disk('public')->delete($product->image);
            }
            $validated['image'] = $request->file('image')->store('products', 'public');
        }

        $product->update($validated);

        return redirect()->route('products.index')
            ->with('success', 'Product updated successfully.');
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy(Product $product)
    {
        if ($product->image) {
            Storage::disk('public')->delete($product->image);
        }
        $product->delete();

        return redirect()->route('products.index')
            ->with('success', 'Product deleted.');
    }
}
Enter fullscreen mode Exit fullscreen mode

File: app/Http/Controllers/RuleController.php

<?php

namespace App\Http\Controllers;

use App\Models\Product;
use App\Models\Rule;
use Illuminate\Http\Request;

class RuleController extends Controller
{
    /**
     * Display a listing of the resource.
     */
    public function index()
    {
        $rules = Rule::with('conditions')->latest()->paginate(10);
        return view('rules.index', compact('rules'));
    }

    /**
     * Show the form for creating a new resource.
     */
    public function create()
    {
        return view('rules.create');
    }

    /**
     * Store a newly created resource in storage.
     */
    public function store(Request $request)
    {
        $request->validate([
            'name'                        => 'required|string|max:255',
            'apply_tags'                  => 'required|string',
            'conditions'                  => 'required|array|min:1',
            'conditions.*.product_selector' => 'required|in:type,sku,vendor,price,qty',
            'conditions.*.operator'       => 'required|in:==,>,<',
            'conditions.*.value'          => 'required|string',
        ]);

        $rule = Rule::create([
            'name'       => $request->name,
            'apply_tags' => $request->apply_tags,
        ]);

        foreach ($request->conditions as $condition) {
            $rule->conditions()->create($condition);
        }

        return redirect()->route('rules.index')
            ->with('success', 'Rule created successfully.');
    }

    /**
     * Display the specified resource.
     */
    public function show(Rule $rule)
    {
        return redirect()->route('rules.index');
    }

    /**
     * Show the form for editing the specified resource.
     */
    public function edit(Rule $rule)
    {
        $rule->load('conditions');
        return view('rules.edit', compact('rule'));
    }

    /**
     * Update the specified resource in storage.
     */
    public function update(Request $request, Rule $rule)
    {
        $request->validate([
            'name'                        => 'required|string|max:255',
            'apply_tags'                  => 'required|string',
            'conditions'                  => 'required|array|min:1',
            'conditions.*.product_selector' => 'required|in:type,sku,vendor,price,qty',
            'conditions.*.operator'       => 'required|in:==,>,<',
            'conditions.*.value'          => 'required|string',
        ]);

        $rule->update([
            'name'       => $request->name,
            'apply_tags' => $request->apply_tags,
        ]);

        // Replace all old conditions
        $rule->conditions()->delete();
        foreach ($request->conditions as $condition) {
            $rule->conditions()->create($condition);
        }

        return redirect()->route('rules.index')
            ->with('success', 'Rule updated successfully.');
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy(Rule $rule)
    {
        $rule->conditions()->delete();
        $rule->delete();

        return redirect()->route('rules.index')
            ->with('success', 'Rule deleted.');
    }

    /**
     * Apply a rule: match all conditions against every product.
     * If ALL conditions match a product, append the rule's tags to it.
     */
    public function applyRule(Rule $rule)
    {
        $rule->load('conditions');
        $products = Product::all();
        $appliedCount = 0;

        foreach ($products as $product) {
            $allMatch = true;

            foreach ($rule->conditions as $condition) {
                $field    = $condition->product_selector; // type, sku, vendor, price, qty
                $operator = $condition->operator;         // ==, >, <
                $value    = $condition->value;
                $actual   = $product->$field;

                $match = match ($operator) {
                    '=='    => strtolower((string)$actual) == strtolower($value),
                    '>'     => is_numeric($actual) && (float)$actual > (float)$value,
                    '<'     => is_numeric($actual) && (float)$actual < (float)$value,
                    default => false,
                };

                if (!$match) {
                    $allMatch = false;
                    break;
                }
            }

            if ($allMatch) {
                // Merge tags (avoid duplicates)
                $existingTags = $product->tags
                    ? array_map('trim', explode(',', $product->tags))
                    : [];

                $newTags = array_map('trim', explode(',', $rule->apply_tags));
                $merged  = array_unique(array_merge($existingTags, $newTags));
                $merged  = array_filter($merged); // remove empties

                $product->tags = implode(', ', $merged);
                $product->save();
                $appliedCount++;
            }
        }

        return redirect()->route('rules.index')
            ->with('success', "Rule applied. Tags added to {$appliedCount} product(s).");
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 5 — Routes

File: routes/web.php

<?php

use App\Http\Controllers\ProductController;
use App\Http\Controllers\RuleController;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return redirect()->route('products.index');
});

// Product Resource Routes
Route::resource('products', ProductController::class)->except(['show']);
Route::get('products/{product}', [ProductController::class, 'show']); // optional

// Rule Resource Routes
Route::resource('rules', RuleController::class)->except(['show']);
Route::post('rules/{rule}/apply', [RuleController::class, 'applyRule'])->name('rules.apply');
Enter fullscreen mode Exit fullscreen mode

Step 6 — Blade Views

Create this folder structure under resources/views/:

layouts/
  app.blade.php
products/
  index.blade.php
  create.blade.php
  edit.blade.php
rules/
  index.blade.php
  create.blade.php
  edit.blade.php
Enter fullscreen mode Exit fullscreen mode

Master Layout — layouts/app.blade.php

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Product Tag Manager</title>
    <!-- TailwindCSS CDN -->
    <script src="https://cdn.tailwindcss.com"></script>
    <!-- Quill.js (Rich Editor) -->
    <link href="https://cdn.quilljs.com/1.3.7/quill.snow.css" rel="stylesheet">
    <script src="https://cdn.quilljs.com/1.3.7/quill.min.js"></script>
    <style>
        .ql-container { min-height: 120px; }
    </style>
</head>
<body class="bg-gray-100 min-h-screen font-sans">

    <!-- Top Navigation -->
    <nav class="bg-white shadow-sm border-b border-gray-200">
        <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
            <div class="flex items-center justify-between h-16">
                <span class="text-xl font-bold text-blue-700 tracking-tight">
                    Product Tag Manager
                </span>
                <div class="flex space-x-2">
                    <a href="{{ route('products.index') }}"
                    class="px-4 py-2 rounded-md text-sm font-medium transition-colors 
                    {{ request()->routeIs('products.*') ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100' }}">
                        Product Manager
                    </a>

                    <a href="{{ route('rules.index') }}"
                    class="px-4 py-2 rounded-md text-sm font-medium transition-colors 
                    {{ request()->routeIs('rules.*') ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100' }}">
                        Rule Manager
                    </a>
                </div>
            </div>
        </div>
    </nav>

    <!-- Flash Messages -->
    <div class="max-w-7xl mx-auto px-4 pt-4">
        @if(session('success'))
            <div class="bg-green-100 border border-green-300 text-green-800 px-4 py-3 rounded-md mb-4 flex items-center justify-between">
                <span>{{ session('success') }}</span>
                <button onclick="this.parentElement.remove()" class="text-green-600 font-bold ml-4">×</button>
            </div>
        @endif
        @if($errors->any())
            <div class="bg-red-100 border border-red-300 text-red-800 px-4 py-3 rounded-md mb-4">
                <ul class="list-disc list-inside text-sm">
                    @foreach($errors->all() as $error)
                        <li>{{ $error }}</li>
                    @endforeach
                </ul>
            </div>
        @endif
    </div>

    <!-- Page Content -->
    <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
        @yield('content')
    </main>

</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Product Manager Views

File: products/index.blade.php

@extends('layouts.app')

@section('content')
<div class="flex items-center justify-between mb-6">
    <h1 class="text-2xl font-bold text-gray-800">Product Manager</h1>
    <a href="{{ route('products.create') }}"
       class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition">
        + Add Product
    </a>
</div>

<div class="bg-white shadow rounded-lg overflow-hidden">
    <table class="min-w-full divide-y divide-gray-200 text-sm">
        <thead class="bg-gray-50">
            <tr>
                <th class="px-4 py-3 text-left font-semibold text-gray-600">#</th>
                <th class="px-4 py-3 text-left font-semibold text-gray-600">Product Name</th>
                <th class="px-4 py-3 text-left font-semibold text-gray-600">SKU</th>
                <th class="px-4 py-3 text-left font-semibold text-gray-600">Price</th>
                <th class="px-4 py-3 text-left font-semibold text-gray-600">Vendor</th>
                <th class="px-4 py-3 text-left font-semibold text-gray-600">Qty</th>
                <th class="px-4 py-3 text-left font-semibold text-gray-600">Type</th>
                <th class="px-4 py-3 text-left font-semibold text-gray-600">Tags</th>
                <th class="px-4 py-3 text-left font-semibold text-gray-600">Action</th>
            </tr>
        </thead>
        <tbody class="divide-y divide-gray-100">
            @forelse($products as $product)
            <tr class="hover:bg-gray-50">
                <td class="px-4 py-3 text-gray-500">{{ $loop->iteration }}</td>
                <td class="px-4 py-3 font-medium text-gray-800">{{ $product->name }}</td>
                <td class="px-4 py-3 text-gray-600">{{ $product->sku }}</td>
                <td class="px-4 py-3 text-gray-600">${{ number_format($product->price, 2) }}</td>
                <td class="px-4 py-3 text-gray-600">{{ $product->vendor ?? '—' }}</td>
                <td class="px-4 py-3 text-gray-600">{{ $product->qty ?? '—' }}</td>
                <td class="px-4 py-3 text-gray-600">{{ $product->type ?? '—' }}</td>
                <td class="px-4 py-3">
                    @if($product->tags)
                        @foreach(explode(',', $product->tags) as $tag)
                            <span class="inline-block bg-blue-100 text-blue-700 text-xs px-2 py-0.5 rounded-full mr-1">
                                {{ trim($tag) }}
                            </span>
                        @endforeach
                    @else
                        <span class="text-gray-400 text-xs">No tags</span>
                    @endif
                </td>
                <td class="px-4 py-3 flex items-center space-x-2">
                    <a href="{{ route('products.edit', $product) }}"
                       class="text-blue-600 hover:underline text-xs font-medium">Edit</a>
                    <form action="{{ route('products.destroy', $product) }}" method="POST"
                          onsubmit="return confirm('Delete this product?')">
                        @csrf @method('DELETE')
                        <button type="submit"
                                class="text-red-500 hover:underline text-xs font-medium">Delete</button>
                    </form>
                </td>
            </tr>
            @empty
            <tr>
                <td colspan="8" class="px-4 py-8 text-center text-gray-400">
                    No products found. <a href="{{ route('products.create') }}" class="text-blue-600 underline">Add one</a>.
                </td>
            </tr>
            @endforelse
        </tbody>
    </table>
</div>

@if($products->hasPages())
<div class="mt-4">{{ $products->links() }}</div>
@endif
@endsection
Enter fullscreen mode Exit fullscreen mode

File: products/create.blade.php

@extends('layouts.app')

@section('content')
<div class="flex items-center justify-between mb-6">
    <h1 class="text-2xl font-bold text-gray-800">Add Product</h1>
    <a href="{{ route('products.index') }}" class="text-sm text-blue-600 hover:underline">← Back to list</a>
</div>

<div class="bg-white shadow rounded-lg p-6 max-w-3xl">
    <form action="{{ route('products.store') }}" method="POST" enctype="multipart/form-data">
        @csrf

        <!-- Product Name -->
        <div class="mb-4">
            <label class="block text-sm font-medium text-gray-700 mb-1">Product Name <span class="text-red-500">*</span></label>
            <input type="text" name="name" value="{{ old('name') }}"
                   class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
                   placeholder="Enter product name">
        </div>

        <!-- Product Description (Quill Editor) -->
        <div class="mb-4">
            <label class="block text-sm font-medium text-gray-700 mb-1">Product Description</label>
            <div id="quill-editor" class="bg-white border border-gray-300 rounded-md"></div>
            <input type="hidden" name="description" id="description-input">
        </div>

        <!-- Price & SKU -->
        <div class="grid grid-cols-2 gap-4 mb-4">
            <div>
                <label class="block text-sm font-medium text-gray-700 mb-1">Product Price <span class="text-red-500">*</span></label>
                <input type="number" name="price" step="0.01" value="{{ old('price') }}"
                       class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
                       placeholder="0.00">
            </div>
            <div>
                <label class="block text-sm font-medium text-gray-700 mb-1">Product SKU <span class="text-red-500">*</span></label>
                <input type="text" name="sku" value="{{ old('sku') }}"
                       class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
                       placeholder="e.g. WA001">
            </div>
        </div>

        <!-- Qty & Type -->
        <div class="grid grid-cols-2 gap-4 mb-4">
            <div>
                <label class="block text-sm font-medium text-gray-700 mb-1">Product Qty <span class="text-red-500">*</span></label>
                <input type="number" name="qty" value="{{ old('qty', 0) }}"
                       class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400">
            </div>
            <div>
                <label class="block text-sm font-medium text-gray-700 mb-1">Product Type</label>
                <input type="text" name="type" value="{{ old('type') }}"
                       class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
                       placeholder="e.g. Type1">
            </div>
        </div>

        <!-- Vendor -->
        <div class="mb-4">
            <label class="block text-sm font-medium text-gray-700 mb-1">Product Vendor</label>
            <input type="text" name="vendor" value="{{ old('vendor') }}"
                   class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
                   placeholder="e.g. Supp.X">
        </div>

        <!-- Product Image -->
        <div class="mb-4">
            <label class="block text-sm font-medium text-gray-700 mb-1">Product Image</label>
            <input type="file" name="image" accept="image/*"
                   class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-400">
        </div>

        <!-- Product Tags (non-editable) -->
        <div class="mb-6">
            <label class="block text-sm font-medium text-gray-700 mb-1">Product Tags
                <span class="text-gray-400 text-xs">(set automatically by rules, non-editable)</span>
            </label>
            <input type="text" disabled value=""
                   class="w-full border border-gray-200 bg-gray-50 rounded-md px-3 py-2 text-sm text-gray-400 cursor-not-allowed"
                   placeholder="Tags will be applied by rules">
        </div>

        <button type="submit"
                class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md font-medium text-sm transition">
            SUBMIT PRODUCT
        </button>
    </form>
</div>

<script>
    const quill = new Quill('#quill-editor', { theme: 'snow' });
    document.querySelector('form').addEventListener('submit', function () {
        document.getElementById('description-input').value = quill.root.innerHTML;
    });
</script>
@endsection
Enter fullscreen mode Exit fullscreen mode

File: products/edit.blade.php

@extends('layouts.app')

@section('content')
<div class="flex items-center justify-between mb-6">
    <h1 class="text-2xl font-bold text-gray-800">Edit Product</h1>
    <a href="{{ route('products.index') }}" class="text-sm text-blue-600 hover:underline">← Back to list</a>
</div>

<div class="bg-white shadow rounded-lg p-6 max-w-3xl">
    <form action="{{ route('products.update', $product) }}" method="POST" enctype="multipart/form-data">
        @csrf @method('PUT')

        <div class="mb-4">
            <label class="block text-sm font-medium text-gray-700 mb-1">Product Name <span class="text-red-500">*</span></label>
            <input type="text" name="name" value="{{ old('name', $product->name) }}"
                   class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400">
        </div>

        <div class="mb-4">
            <label class="block text-sm font-medium text-gray-700 mb-1">Product Description</label>
            <div id="quill-editor" class="bg-white border border-gray-300 rounded-md"></div>
            <input type="hidden" name="description" id="description-input">
        </div>

        <div class="grid grid-cols-2 gap-4 mb-4">
            <div>
                <label class="block text-sm font-medium text-gray-700 mb-1">Product Price <span class="text-red-500">*</span></label>
                <input type="number" name="price" step="0.01" value="{{ old('price', $product->price) }}"
                       class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400">
            </div>
            <div>
                <label class="block text-sm font-medium text-gray-700 mb-1">Product SKU <span class="text-red-500">*</span></label>
                <input type="text" name="sku" value="{{ old('sku', $product->sku) }}"
                       class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400">
            </div>
        </div>

        <div class="grid grid-cols-2 gap-4 mb-4">
            <div>
                <label class="block text-sm font-medium text-gray-700 mb-1">Product Qty <span class="text-red-500">*</span></label>
                <input type="number" name="qty" value="{{ old('qty', $product->qty) }}"
                       class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400">
            </div>
            <div>
                <label class="block text-sm font-medium text-gray-700 mb-1">Product Type</label>
                <input type="text" name="type" value="{{ old('type', $product->type) }}"
                       class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400">
            </div>
        </div>

        <div class="mb-4">
            <label class="block text-sm font-medium text-gray-700 mb-1">Product Vendor</label>
            <input type="text" name="vendor" value="{{ old('vendor', $product->vendor) }}"
                   class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400">
        </div>

        <div class="mb-4">
            <label class="block text-sm font-medium text-gray-700 mb-1">Product Image</label>
            @if($product->image)
                <div class="mb-2">
                    <img src="{{ Storage::url($product->image) }}" alt="Current Image"
                         class="h-20 w-20 object-cover rounded border">
                    <p class="text-xs text-gray-400 mt-1">Current image. Upload new to replace.</p>
                </div>
            @endif
            <input type="file" name="image" accept="image/*"
                   class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-400">
        </div>

        <!-- Tags — non-editable display -->
        <div class="mb-6">
            <label class="block text-sm font-medium text-gray-700 mb-1">Product Tags
                <span class="text-gray-400 text-xs">(managed by rules only)</span>
            </label>
            <input type="text" disabled value="{{ $product->tags }}"
                   class="w-full border border-gray-200 bg-gray-50 rounded-md px-3 py-2 text-sm text-gray-500 cursor-not-allowed">
        </div>

        <button type="submit"
                class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md font-medium text-sm transition">
            UPDATE PRODUCT
        </button>
    </form>
</div>

<script>
    const quill = new Quill('#quill-editor', { theme: 'snow' });
    quill.root.innerHTML = `{!! addslashes($product->description ?? '') !!}`;
    document.querySelector('form').addEventListener('submit', function () {
        document.getElementById('description-input').value = quill.root.innerHTML;
    });
</script>
@endsection
Enter fullscreen mode Exit fullscreen mode

Rule Manager Views

File: rules/index.blade.php

@extends('layouts.app')

@section('content')
<div class="flex items-center justify-between mb-6">
    <h1 class="text-2xl font-bold text-gray-800">Rule Manager</h1>
    <a href="{{ route('rules.create') }}"
       class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition">
        + Add Rule
    </a>
</div>

<div class="bg-white shadow rounded-lg overflow-hidden">
    <table class="min-w-full divide-y divide-gray-200 text-sm">
        <thead class="bg-gray-50">
            <tr>
                <th class="px-4 py-3 text-left font-semibold text-gray-600">#</th>
                <th class="px-4 py-3 text-left font-semibold text-gray-600">Rule Name</th>
                <th class="px-4 py-3 text-left font-semibold text-gray-600">Apply Tags</th>
                <th class="px-4 py-3 text-left font-semibold text-gray-600">Action (Edit)</th>
                <th class="px-4 py-3 text-left font-semibold text-gray-600">Action</th>
            </tr>
        </thead>
        <tbody class="divide-y divide-gray-100">
            @forelse($rules as $rule)
            <tr class="hover:bg-gray-50">
                <td class="px-4 py-3 text-gray-500">{{ $loop->iteration }}</td>
                <td class="px-4 py-3 font-medium text-gray-800">{{ $rule->name }}</td>
                <td class="px-4 py-3">
                    @foreach(explode(',', $rule->apply_tags) as $tag)
                        <span class="inline-block bg-green-100 text-green-700 text-xs px-2 py-0.5 rounded-full mr-1">
                            {{ trim($tag) }}
                        </span>
                    @endforeach
                </td>
                <td class="px-4 py-3 flex items-center space-x-2">
                    <a href="{{ route('rules.edit', $rule) }}"
                       class="text-blue-600 hover:underline text-xs font-medium">Edit</a>
                    <form action="{{ route('rules.destroy', $rule) }}" method="POST"
                          onsubmit="return confirm('Delete this rule?')">
                        @csrf @method('DELETE')
                        <button type="submit" class="text-red-500 hover:underline text-xs font-medium">Delete</button>
                    </form>
                </td>
                <td class="px-4 py-3">
                    <form action="{{ route('rules.apply', $rule) }}" method="POST">
                        @csrf
                        <button type="submit"
                                class="bg-blue-600 hover:bg-blue-700 text-white text-xs px-3 py-1.5 rounded font-medium transition">
                            Apply Rule
                        </button>
                    </form>
                </td>
            </tr>
            @empty
            <tr>
                <td colspan="5" class="px-4 py-8 text-center text-gray-400">
                    No rules found. <a href="{{ route('rules.create') }}" class="text-blue-600 underline">Create one</a>.
                </td>
            </tr>
            @endforelse
        </tbody>
    </table>
</div>

@if($rules->hasPages())
<div class="mt-4">{{ $rules->links() }}</div>
@endif
@endsection
Enter fullscreen mode Exit fullscreen mode

File: rules/create.blade.php

@extends('layouts.app')

@section('content')
<div class="flex items-center justify-between mb-6">
    <h1 class="text-2xl font-bold text-gray-800">Create Rule</h1>
    <a href="{{ route('rules.index') }}" class="text-sm text-blue-600 hover:underline">← Back to list</a>
</div>

<div class="bg-white shadow rounded-lg p-6 max-w-3xl">
    <form action="{{ route('rules.store') }}" method="POST" id="rule-form">
        @csrf

        <!-- Rule Name -->
        <div class="mb-4">
            <label class="block text-sm font-medium text-gray-700 mb-1">Rule Name <span class="text-red-500">*</span></label>
            <input type="text" name="name" value="{{ old('name') }}"
                   class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
                   placeholder="e.g. VIP Products">
        </div>

        <!-- Rule Conditions -->
        <div class="mb-4">
            <label class="block text-sm font-medium text-gray-700 mb-2">
                Rule Conditions <span class="text-red-500">*</span>
                <span class="text-gray-400 text-xs font-normal">(All conditions must match)</span>
            </label>

            <div id="conditions-wrapper" class="space-y-3">
                <!-- Condition Row Template (first row) -->
                <div class="condition-row flex items-center gap-2 bg-gray-50 p-3 rounded-md border border-gray-200">
                    <select name="conditions[0][product_selector]"
                            class="border border-gray-300 rounded-md px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400">
                        <option value="">Choose Selector</option>
                        <option value="type">Type</option>
                        <option value="sku">SKU</option>
                        <option value="vendor">Vendor</option>
                        <option value="price">Price</option>
                        <option value="qty">Qty</option>
                    </select>

                    <select name="conditions[0][operator]"
                            class="border border-gray-300 rounded-md px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400">
                        <option value="">Operator</option>
                        <option value="==">==</option>
                        <option value=">">&gt;</option>
                        <option value="<">&lt;</option>
                    </select>

                    <input type="text" name="conditions[0][value]" placeholder="Value"
                           class="flex-1 border border-gray-300 rounded-md px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400">

                    <button type="button" onclick="removeCondition(this)"
                            class="text-red-400 hover:text-red-600 font-bold text-lg leading-none px-1">×</button>
                </div>
            </div>

            <button type="button" onclick="addCondition()"
                    class="mt-3 flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 font-medium">
                <span class="text-lg leading-none">+</span> Add More Conditions
            </button>
        </div>

        <!-- Apply Tags -->
        <div class="mb-6">
            <label class="block text-sm font-medium text-gray-700 mb-1">
                Apply Tags <span class="text-red-500">*</span>
                <span class="text-gray-400 text-xs font-normal">(comma-separated)</span>
            </label>
            <input type="text" name="apply_tags" value="{{ old('apply_tags') }}"
                   class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
                   placeholder="e.g. VIP, Gold, Premium">
        </div>

        <button type="submit"
                class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md font-medium text-sm transition">
            SAVE RULE
        </button>
    </form>
</div>

<script>
let conditionIndex = 1;

function addCondition() {
    const wrapper = document.getElementById('conditions-wrapper');
    const idx = conditionIndex++;
    const row = document.createElement('div');
    row.className = 'condition-row flex items-center gap-2 bg-gray-50 p-3 rounded-md border border-gray-200';
    row.innerHTML = `
        <select name="conditions[${idx}][product_selector]"
                class="border border-gray-300 rounded-md px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400">
            <option value="">Choose Selector</option>
            <option value="type">Type</option>
            <option value="sku">SKU</option>
            <option value="vendor">Vendor</option>
            <option value="price">Price</option>
            <option value="qty">Qty</option>
        </select>
        <select name="conditions[${idx}][operator]"
                class="border border-gray-300 rounded-md px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400">
            <option value="">Operator</option>
            <option value="==">==</option>
            <option value=">">&gt;</option>
            <option value="<">&lt;</option>
        </select>
        <input type="text" name="conditions[${idx}][value]" placeholder="Value"
               class="flex-1 border border-gray-300 rounded-md px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400">
        <button type="button" onclick="removeCondition(this)"
                class="text-red-400 hover:text-red-600 font-bold text-lg leading-none px-1">×</button>
    `;
    wrapper.appendChild(row);
}

function removeCondition(btn) {
    const rows = document.querySelectorAll('.condition-row');
    if (rows.length > 1) {
        btn.closest('.condition-row').remove();
    } else {
        alert('At least one condition is required.');
    }
}
</script>
@endsection
Enter fullscreen mode Exit fullscreen mode

File: rules/edit.blade.php

@extends('layouts.app')

@section('content')
<div class="flex items-center justify-between mb-6">
    <h1 class="text-2xl font-bold text-gray-800">Edit Rule</h1>
    <a href="{{ route('rules.index') }}" class="text-sm text-blue-600 hover:underline">← Back to list</a>
</div>

<div class="bg-white shadow rounded-lg p-6 max-w-3xl">
    <form action="{{ route('rules.update', $rule) }}" method="POST" id="rule-form">
        @csrf @method('PUT')

        <div class="mb-4">
            <label class="block text-sm font-medium text-gray-700 mb-1">Rule Name <span class="text-red-500">*</span></label>
            <input type="text" name="name" value="{{ old('name', $rule->name) }}"
                   class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400">
        </div>

        <div class="mb-4">
            <label class="block text-sm font-medium text-gray-700 mb-2">
                Rule Conditions <span class="text-red-500">*</span>
            </label>
            <div id="conditions-wrapper" class="space-y-3">
                @foreach($rule->conditions as $i => $condition)
                <div class="condition-row flex items-center gap-2 bg-gray-50 p-3 rounded-md border border-gray-200">
                    <select name="conditions[{{ $i }}][product_selector]"
                            class="border border-gray-300 rounded-md px-2 py-1.5 text-sm">
                        @foreach(['type','sku','vendor','price','qty'] as $opt)
                            <option value="{{ $opt }}" {{ $condition->product_selector === $opt ? 'selected' : '' }}>
                                {{ ucfirst($opt) }}
                            </option>
                        @endforeach
                    </select>
                    <select name="conditions[{{ $i }}][operator]"
                            class="border border-gray-300 rounded-md px-2 py-1.5 text-sm">
                        @foreach(['==','>','<'] as $op)
                            <option value="{{ $op }}" {{ $condition->operator === $op ? 'selected' : '' }}>{{ $op }}</option>
                        @endforeach
                    </select>
                    <input type="text" name="conditions[{{ $i }}][value]" value="{{ $condition->value }}"
                           class="flex-1 border border-gray-300 rounded-md px-2 py-1.5 text-sm">
                    <button type="button" onclick="removeCondition(this)"
                            class="text-red-400 hover:text-red-600 font-bold text-lg leading-none px-1">×</button>
                </div>
                @endforeach
            </div>
            <button type="button" onclick="addCondition()"
                    class="mt-3 flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 font-medium">
                <span class="text-lg leading-none">+</span> Add More Conditions
            </button>
        </div>

        <div class="mb-6">
            <label class="block text-sm font-medium text-gray-700 mb-1">
                Apply Tags <span class="text-red-500">*</span>
                <span class="text-gray-400 text-xs font-normal">(comma-separated)</span>
            </label>
            <input type="text" name="apply_tags" value="{{ old('apply_tags', $rule->apply_tags) }}"
                   class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400">
        </div>

        <button type="submit"
                class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md font-medium text-sm transition">
            UPDATE RULE
        </button>
    </form>
</div>

<script>
let conditionIndex = {{ $rule->conditions->count() }};

function addCondition() {
    const wrapper = document.getElementById('conditions-wrapper');
    const idx = conditionIndex++;
    const row = document.createElement('div');
    row.className = 'condition-row flex items-center gap-2 bg-gray-50 p-3 rounded-md border border-gray-200';
    row.innerHTML = `
        <select name="conditions[${idx}][product_selector]"
                class="border border-gray-300 rounded-md px-2 py-1.5 text-sm">
            <option value="">Choose Selector</option>
            <option value="type">Type</option>
            <option value="sku">SKU</option>
            <option value="vendor">Vendor</option>
            <option value="price">Price</option>
            <option value="qty">Qty</option>
        </select>
        <select name="conditions[${idx}][operator]"
                class="border border-gray-300 rounded-md px-2 py-1.5 text-sm">
            <option value="">Operator</option>
            <option value="==">==</option>
            <option value=">">&gt;</option>
            <option value="<">&lt;</option>
        </select>
        <input type="text" name="conditions[${idx}][value]" placeholder="Value"
               class="flex-1 border border-gray-300 rounded-md px-2 py-1.5 text-sm">
        <button type="button" onclick="removeCondition(this)"
                class="text-red-400 hover:text-red-600 font-bold text-lg px-1">×</button>
    `;
    wrapper.appendChild(row);
}

function removeCondition(btn) {
    const rows = document.querySelectorAll('.condition-row');
    if (rows.length > 1) {
        btn.closest('.condition-row').remove();
    } else {
        alert('At least one condition is required.');
    }
}
</script>
@endsection
Enter fullscreen mode Exit fullscreen mode

Step 7 — Storage Link for Images

php artisan storage:link
Enter fullscreen mode Exit fullscreen mode

Make sure config/filesystems.php has this (default in Laravel):

'public' => [
    'driver'     => 'local',
    'root'       => storage_path('app/public'),
    'url'        => env('APP_URL').'/storage',
    'visibility' => 'public',
],
Enter fullscreen mode Exit fullscreen mode

Step 8 — Run & Test

php artisan serve
Enter fullscreen mode Exit fullscreen mode

Visit http://localhost:8000 and test the flow:

  1. Add Products → fill in name, price, SKU, vendor, type, qty, image
  2. Note the Tags field is disabled (non-editable — only rules can set it)
  3. Go to Rule Manager → Create a rule with conditions like vendor == Supp.X AND price > 10
  4. Set Apply Tags to VIP, Gold
  5. Click Apply Rule
  6. Go back to products → matching products will now have VIP, Gold in their Tags column

Step 9 — Push to GitHub

git init
git add .
git commit -m "First Commit"
git remote add origin https://github.com/YOUR_USERNAME/product-tag-manager.git
git branch -M main
git push -u origin main
Enter fullscreen mode Exit fullscreen mode

How the Rule Engine Works

When Apply Rule is clicked, RuleController::applyRule() runs this logic:

For each Product:
  → Check ALL rule conditions
  → condition: product.{selector} {operator} {value}
     == → case-insensitive string/numeric match
     >  → numeric greater-than
     <  → numeric less-than
  → If ALL pass → merge tags (no duplicates) → save
  → If ANY fail → skip product
Enter fullscreen mode Exit fullscreen mode

The tags are merged, not replaced — so a product can accumulate tags from multiple rules without losing previously applied ones.


File Structure

app/Http/Controllers/
  ProductController.php     ← Resource CRUD + image upload
  RuleController.php        ← Resource CRUD + applyRule()
app/Models/
  Product.php
  Rule.php                  ← hasMany(RuleCondition)
  RuleCondition.php         ← belongsTo(Rule)
database/migrations/
  create_products_table.php
  create_rules_table.php
  create_rule_conditions_table.php
resources/views/
  layouts/app.blade.php
  products/ → index, create, edit
  rules/    → index, create, edit
routes/web.php
Enter fullscreen mode Exit fullscreen mode

That's it! You now have a fully working rule-based product tagging system in Laravel.

Github Repo: github.com/chiragpatel009/product-tag-manager

If you found this helpful, drop a ❤️ and follow for more Laravel practicals!

Top comments (0)