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.XANDprice > 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
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=
Create the database:
CREATE DATABASE product_tag_manager CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
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
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();
});
}
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();
});
}
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();
});
}
Run migrations:
php artisan migrate
Step 3 — Models
Generate the model files:
php artisan make:model Product
php artisan make:model Rule
php artisan make:model RuleCondition
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',
];
}
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);
}
}
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);
}
}
Step 4 — Resource Controllers
Generate the controller files:
php artisan make:controller ProductController --resource
php artisan make:controller RuleController --resource
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.');
}
}
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).");
}
}
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');
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
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>
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
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
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
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
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=">">></option>
<option value="<"><</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=">">></option>
<option value="<"><</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
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=">">></option>
<option value="<"><</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
Step 7 — Storage Link for Images
php artisan storage:link
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',
],
Step 8 — Run & Test
php artisan serve
Visit http://localhost:8000 and test the flow:
- Add Products → fill in name, price, SKU, vendor, type, qty, image
- Note the Tags field is disabled (non-editable — only rules can set it)
- Go to Rule Manager → Create a rule with conditions like
vendor == Supp.XANDprice > 10 - Set Apply Tags to
VIP, Gold - Click Apply Rule
- Go back to products → matching products will now have
VIP, Goldin 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
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
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
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)